Identité dans le chat, section Paramètres, logo agrandi

- Identité : prénom et avatars (emoji) affichés en tête des bulles, à la
  place des libellés « user » / « assistant ». Stockés dans les préférences
  SERVEUR, donc partagés entre appareils comme le thème. La liste d'emojis
  est servie par /api/prefs : pas de seconde copie côté client.
  Validation stricte — prénom nettoyé de ses caractères de contrôle et borné
  à 24 runes, avatar refusé s'il n'est pas dans la liste fermée.
- Barre latérale : séparateur « Paramètres » qui regroupe Identité,
  Apparence, Accès OpenAI, Accès distant et Actions, sous les réglages d'IA
  (Machine, Presets, Mode agent, System prompt, Moteur). Un séparateur plutôt
  qu'un second niveau de repli, illisible dans une colonne aussi étroite.
- Logo : 10 → 17 px, il était minuscule sur un écran dense.

Le prénom vient de l'utilisateur : les libellés sont écrits en textContent,
jamais en innerHTML.
This commit is contained in:
Loki committed 2026-08-15 11:35:40 +00:00
1 parent ecaa847439
commit 54c5eb1334
7 files changed
+417 -121

No files matched your search

+182 -60
View File
@@ -130,7 +130,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
/* Logo : « Loki » dessiné en pixels (SVG 4x5 par lettre, trait d'un module). Le « A »
reste un bloc à part : il apparaît en glissant quand le mode agent est actif. */
#brand{display:inline-flex;align-items:center}
#brand svg{height:10px;width:auto;display:block;shape-rendering:crispEdges;fill:currentColor}
/* 17px : le logo pixel-art était minuscule à 10px, illisible sur un écran dense.
Multiple impair volontaire — la grille du SVG (5 px de haut) reste nette. */
#brand svg{height:17px;width:auto;display:block;shape-rendering:crispEdges;fill:currentColor}
#brand-word{display:inline-flex;color:var(--accent)}
#ver{font-size:10px;font-weight:400;color:var(--dim);margin-left:7px;letter-spacing:0;align-self:flex-end;line-height:1;position:relative;top:1px}
.sidehead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}
@@ -146,6 +148,21 @@ h1{margin:0;font-size:18px;color:var(--accent)}
.preset:hover .conv-acts,.preset:focus-within .conv-acts{opacity:1}
.conv-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer}
.conv-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
/* Séparateur de groupe dans la barre latérale : sépare les réglages d'IA des
réglages d'application, sans imbriquer un second niveau de repli (illisible
dans une colonne aussi étroite). */
.side-sep{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--dim);
margin:18px 0 8px;padding-top:12px;border-top:1px solid var(--border)}
/* Choix d'avatar : une grille de pastilles emoji, celle retenue encadrée. */
.avpick{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
.avpick button{font-size:15px;line-height:1;padding:5px 6px;background:var(--panel);
border:1px solid var(--border);border-radius:8px;cursor:pointer}
.avpick button:hover{border-color:var(--accent)}
.avpick button.sel{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 15%,transparent)}
/* Avatar + nom en tête de bulle : l'emoji garde sa taille propre, le nom hérite
du style de .label (petites capitales) mais sans forcer la casse du prénom. */
.msg .label .av{font-style:normal;font-size:13px;margin-right:5px;vertical-align:-1px}
.msg .label .who{text-transform:none;letter-spacing:.04em;font-size:11px}
/* pastille d'état service, en haut à droite de la barre latérale */
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
@@ -1343,6 +1360,85 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div>
</div>
</details>
<details><summary><span class="stitle">System prompt</span></summary>
<div class="slist"><div class="rowbody">
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
</div></div>
</details>
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
même sujet. Les trois modes sont des LIGNES de cette carte (elles
étaient des cartes dans une carte). -->
<div class="slist">
<!-- Moteur fourni par l'image Docker : aucun des trois modes ne s'applique
(rien à installer, et l'image n'embarque ni cmake ni compilateur). -->
<div id="lc-provided" class="rowbody" style="display:none">
<div style="font-size:12px;line-height:1.5">Moteur <b>fourni par l'image Docker</b> — llama.cpp officiel, déjà compilé.
<div class="muted" style="margin-top:4px">Rien à installer ni à mettre à jour ici : le moteur suit l'image.
Pour en changer (version épinglée, CPU, Vulkan), reconstruis l'image avec le build-arg <code>LLAMACPP_IMAGE</code>.</div>
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
</div>
</div>
<!-- La pastille « conseillé » n'est PAS écrite ici : le bon choix dépend de
la machine (voir lcRenderReco). Sur Linux + NVIDIA le précompilé ne
donne que du Vulkan — llama.cpp ne publie aucun binaire CUDA Linux —
alors que compiler donne du CUDA natif. -->
<div class="lc-modes">
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')">
<div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco" id="lc-reco-fast" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-fast">binaire officiel téléchargé tel quel, prêt en ~2 min</div>
<div class="lc-mode-state" id="lc-fast-state"></div>
</div>
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')">
<div class="lc-mode-t">llama.cpp compilé <span class="lc-reco" id="lc-reco-opt" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-opt">compilé ici pour votre machine — plus rapide, plus long à installer</div>
<div class="lc-mode-state" id="lc-opt-state"></div>
</div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
<div class="lc-mode-t">llama.cpp personnalisé</div>
<div class="lc-mode-d">un fork de llama.cpp pour modèles spéciaux (quant ternaire…)</div>
<div class="lc-mode-state" id="lc-custom-state"></div>
</div>
</div>
<div id="lc-job" class="rowbody" style="display:none">
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
<div class="lc-logbar" onclick="lcToggleLog()"><span id="lc-log-caret">▸</span> détails</div>
<pre id="lc-log" hidden></pre>
</div>
</div>
<!-- Ces trois boutons pilotent le service qui fait tourner llama-server :
leur place est ici, avec le moteur, et pas dans une section « Service »
à part qui ne contenait qu'eux. -->
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).</span></span></div>
<div class="slist"><div class="rowbody">
<div class="row" style="margin:0">
<button onclick="act('start')">démarrer</button>
<button onclick="act('restart')">redémarrer</button>
<button onclick="act('stop')">arrêter</button>
</div>
</div></div>
</details>
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
l'application elle-même. -->
<div class="side-sep">Paramètres</div>
<details id="ident-details"><summary><span class="stitle">Identité</span></summary>
<div class="slist"><div class="rowbody">
<div class="pe-row">
<span class="pe-row-l">Ton prénom<span class="pe-sub">affiché sur tes messages</span></span>
<span class="pe-row-c"><input id="p-username" class="pe-inp" maxlength="24" placeholder="toi" oninput="saveIdentity()"></span>
</div>
<div class="pe-row stack">
<span class="pe-row-l">Ton avatar</span>
<div id="p-avatar-user" class="avpick"></div>
</div>
<div class="pe-row stack">
<span class="pe-row-l">Avatar de Loki</span>
<div id="p-avatar-ai" class="avpick"></div>
</div>
</div></div>
</details>
<details><summary><span class="stitle">Apparence</span></summary>
<div class="slist">
<label class="switchrow rev"><span>mode sombre</span>
@@ -1358,11 +1454,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-stats" onchange="toggleView('hide-stats')"><span class="slider"></span></span> masquer la vitesse de génération<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Sous chaque réponse : <b>prefill</b> (lecture du contexte) et <b>decode</b> (écriture), en tokens et en tokens/seconde. Coche pour ne plus les afficher.</span></span></label>
</div>
</details>
<details><summary><span class="stitle">System prompt</span></summary>
<div class="slist"><div class="rowbody">
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
</div></div>
</details>
<details><summary><span class="stitle">Accès OpenAI</span></summary>
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer &lt;clé&gt;</code>.</span></span></div>
<!-- UNE carte pour tout l'endpoint : adresse locale, bascule « public » et
@@ -1434,59 +1525,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div></div>
</div>
</details>
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
même sujet. Les trois modes sont des LIGNES de cette carte (elles
étaient des cartes dans une carte). -->
<div class="slist">
<!-- Moteur fourni par l'image Docker : aucun des trois modes ne s'applique
(rien à installer, et l'image n'embarque ni cmake ni compilateur). -->
<div id="lc-provided" class="rowbody" style="display:none">
<div style="font-size:12px;line-height:1.5">Moteur <b>fourni par l'image Docker</b> — llama.cpp officiel, déjà compilé.
<div class="muted" style="margin-top:4px">Rien à installer ni à mettre à jour ici : le moteur suit l'image.
Pour en changer (version épinglée, CPU, Vulkan), reconstruis l'image avec le build-arg <code>LLAMACPP_IMAGE</code>.</div>
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
</div>
</div>
<!-- La pastille « conseillé » n'est PAS écrite ici : le bon choix dépend de
la machine (voir lcRenderReco). Sur Linux + NVIDIA le précompilé ne
donne que du Vulkan — llama.cpp ne publie aucun binaire CUDA Linux —
alors que compiler donne du CUDA natif. -->
<div class="lc-modes">
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')">
<div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco" id="lc-reco-fast" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-fast">binaire officiel téléchargé tel quel, prêt en ~2 min</div>
<div class="lc-mode-state" id="lc-fast-state"></div>
</div>
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')">
<div class="lc-mode-t">llama.cpp compilé <span class="lc-reco" id="lc-reco-opt" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-opt">compilé ici pour votre machine — plus rapide, plus long à installer</div>
<div class="lc-mode-state" id="lc-opt-state"></div>
</div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
<div class="lc-mode-t">llama.cpp personnalisé</div>
<div class="lc-mode-d">un fork de llama.cpp pour modèles spéciaux (quant ternaire…)</div>
<div class="lc-mode-state" id="lc-custom-state"></div>
</div>
</div>
<div id="lc-job" class="rowbody" style="display:none">
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
<div class="lc-logbar" onclick="lcToggleLog()"><span id="lc-log-caret">▸</span> détails</div>
<pre id="lc-log" hidden></pre>
</div>
</div>
<!-- Ces trois boutons pilotent le service qui fait tourner llama-server :
leur place est ici, avec le moteur, et pas dans une section « Service »
à part qui ne contenait qu'eux. -->
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).</span></span></div>
<div class="slist"><div class="rowbody">
<div class="row" style="margin:0">
<button onclick="act('start')">démarrer</button>
<button onclick="act('restart')">redémarrer</button>
<button onclick="act('stop')">arrêter</button>
</div>
</div></div>
</details>
<details id="upd-details"><summary><span class="stitle">Actions</span></summary>
<div class="slist"><div class="rowbody">
<!-- Ligne 1 : mise à jour + export du fil. Depuis la 0.8 la conversation vit
@@ -2977,7 +3015,7 @@ async function loadAll(){
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
// hauteurs réservées en place pour toujours.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
}
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
@@ -4032,6 +4070,11 @@ function addMsg(role, text){
el.querySelector('.label').onclick=()=>toggleCollapse(el);
} else {
el.innerHTML='<span class="label">'+role+'</span><div class="body"></div>';
// user / assistant portent un avatar et un nom (voir 17-identity.js) ;
// reasoning et tool gardent leur libellé technique.
if((role==='user'||role==='assistant') && typeof paintLabel==='function'){
paintLabel(el.querySelector('.label'), role);
}
}
el.querySelector('.body').textContent=text;
chatEl().appendChild(el);
@@ -5844,4 +5887,83 @@ async function convDelete(id, title){
loadConversations();
}catch(_){ toast('erreur réseau'); }
}
// Identité — prénom et avatars affichés en tête des bulles du chat.
//
// Les valeurs vivent dans les préférences SERVEUR (comme le thème) : elles
// suivent donc d'un appareil à l'autre, au lieu de rester dans le localStorage
// d'un seul navigateur. La liste d'emojis est celle que sert le serveur — pas
// une seconde copie côté client, qui divergerait à la première modification.
let IDENT = {name:'', user:'🙂', ai:'🦊'};
let AVATARS = [];
// applyIdentity redessine les libellés des bulles DÉJÀ affichées : changer son
// prénom doit se voir tout de suite, sans recharger la page.
function applyIdentity(){
document.querySelectorAll('#chat .msg.user > .label, #chat .msg.assistant > .label')
.forEach(l => paintLabel(l, l.parentElement.classList.contains('user') ? 'user' : 'assistant'));
}
// paintLabel remplit un libellé : avatar + nom. Le prénom vient de
// l'utilisateur, donc textContent — jamais innerHTML.
function paintLabel(label, role){
if(!label) return;
label.textContent = '';
const av = document.createElement('i'); av.className = 'av';
av.textContent = role === 'user' ? (IDENT.user || '🙂') : (IDENT.ai || '🦊');
const who = document.createElement('span'); who.className = 'who';
who.textContent = role === 'user' ? (IDENT.name || 'toi') : 'Loki';
label.appendChild(av); label.appendChild(who);
}
async function loadIdentity(){
let r;
try{ r = await jget('/api/prefs'); }catch(_){ return; }
const p = r.prefs || {};
AVATARS = r.avatars || [];
IDENT = {
name: p.user_name || '',
user: p.avatar_user || '🙂',
ai: p.avatar_ai || '🦊',
};
const inp = document.getElementById('p-username');
if(inp && document.activeElement !== inp) inp.value = IDENT.name;
renderAvatarPicker('p-avatar-user', 'user');
renderAvatarPicker('p-avatar-ai', 'ai');
applyIdentity();
}
function renderAvatarPicker(elID, which){
const box = document.getElementById(elID);
if(!box) return;
box.innerHTML = '';
for(const e of AVATARS){
const b = document.createElement('button');
b.type = 'button'; b.textContent = e;
if(e === IDENT[which]) b.classList.add('sel');
b.onclick = () => pickAvatar(which, e);
box.appendChild(b);
}
}
async function pickAvatar(which, emoji){
IDENT[which] = emoji;
renderAvatarPicker(which === 'user' ? 'p-avatar-user' : 'p-avatar-ai', which);
applyIdentity();
try{ await jpost('/api/prefs', which === 'user' ? {avatar_user:emoji} : {avatar_ai:emoji}); }
catch(_){ toast('erreur réseau'); }
}
// Saisie du prénom : on n'écrit pas au serveur à chaque frappe.
let identTimer = null;
function saveIdentity(){
const inp = document.getElementById('p-username');
if(!inp) return;
IDENT.name = inp.value.trim();
applyIdentity();
clearTimeout(identTimer);
identTimer = setTimeout(async () => {
try{ await jpost('/api/prefs', {user_name: IDENT.name}); }catch(_){ toast('erreur réseau'); }
}, 500);
}
</script></body></html>
+79 -58
View File
@@ -161,6 +161,85 @@
</div>
</div>
</details>
<details><summary><span class="stitle">System prompt</span></summary>
<div class="slist"><div class="rowbody">
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
</div></div>
</details>
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
même sujet. Les trois modes sont des LIGNES de cette carte (elles
étaient des cartes dans une carte). -->
<div class="slist">
<!-- Moteur fourni par l'image Docker : aucun des trois modes ne s'applique
(rien à installer, et l'image n'embarque ni cmake ni compilateur). -->
<div id="lc-provided" class="rowbody" style="display:none">
<div style="font-size:12px;line-height:1.5">Moteur <b>fourni par l'image Docker</b> — llama.cpp officiel, déjà compilé.
<div class="muted" style="margin-top:4px">Rien à installer ni à mettre à jour ici : le moteur suit l'image.
Pour en changer (version épinglée, CPU, Vulkan), reconstruis l'image avec le build-arg <code>LLAMACPP_IMAGE</code>.</div>
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
</div>
</div>
<!-- La pastille « conseillé » n'est PAS écrite ici : le bon choix dépend de
la machine (voir lcRenderReco). Sur Linux + NVIDIA le précompilé ne
donne que du Vulkan — llama.cpp ne publie aucun binaire CUDA Linux —
alors que compiler donne du CUDA natif. -->
<div class="lc-modes">
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')">
<div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco" id="lc-reco-fast" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-fast">binaire officiel téléchargé tel quel, prêt en ~2 min</div>
<div class="lc-mode-state" id="lc-fast-state"></div>
</div>
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')">
<div class="lc-mode-t">llama.cpp compilé <span class="lc-reco" id="lc-reco-opt" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-opt">compilé ici pour votre machine — plus rapide, plus long à installer</div>
<div class="lc-mode-state" id="lc-opt-state"></div>
</div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
<div class="lc-mode-t">llama.cpp personnalisé</div>
<div class="lc-mode-d">un fork de llama.cpp pour modèles spéciaux (quant ternaire…)</div>
<div class="lc-mode-state" id="lc-custom-state"></div>
</div>
</div>
<div id="lc-job" class="rowbody" style="display:none">
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
<div class="lc-logbar" onclick="lcToggleLog()"><span id="lc-log-caret">▸</span> détails</div>
<pre id="lc-log" hidden></pre>
</div>
</div>
<!-- Ces trois boutons pilotent le service qui fait tourner llama-server :
leur place est ici, avec le moteur, et pas dans une section « Service »
à part qui ne contenait qu'eux. -->
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).</span></span></div>
<div class="slist"><div class="rowbody">
<div class="row" style="margin:0">
<button onclick="act('start')">démarrer</button>
<button onclick="act('restart')">redémarrer</button>
<button onclick="act('stop')">arrêter</button>
</div>
</div></div>
</details>
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
l'application elle-même. -->
<div class="side-sep">Paramètres</div>
<details id="ident-details"><summary><span class="stitle">Identité</span></summary>
<div class="slist"><div class="rowbody">
<div class="pe-row">
<span class="pe-row-l">Ton prénom<span class="pe-sub">affiché sur tes messages</span></span>
<span class="pe-row-c"><input id="p-username" class="pe-inp" maxlength="24" placeholder="toi" oninput="saveIdentity()"></span>
</div>
<div class="pe-row stack">
<span class="pe-row-l">Ton avatar</span>
<div id="p-avatar-user" class="avpick"></div>
</div>
<div class="pe-row stack">
<span class="pe-row-l">Avatar de Loki</span>
<div id="p-avatar-ai" class="avpick"></div>
</div>
</div></div>
</details>
<details><summary><span class="stitle">Apparence</span></summary>
<div class="slist">
<label class="switchrow rev"><span>mode sombre</span>
@@ -176,11 +255,6 @@
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-stats" onchange="toggleView('hide-stats')"><span class="slider"></span></span> masquer la vitesse de génération<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Sous chaque réponse : <b>prefill</b> (lecture du contexte) et <b>decode</b> (écriture), en tokens et en tokens/seconde. Coche pour ne plus les afficher.</span></span></label>
</div>
</details>
<details><summary><span class="stitle">System prompt</span></summary>
<div class="slist"><div class="rowbody">
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
</div></div>
</details>
<details><summary><span class="stitle">Accès OpenAI</span></summary>
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer &lt;clé&gt;</code>.</span></span></div>
<!-- UNE carte pour tout l'endpoint : adresse locale, bascule « public » et
@@ -252,59 +326,6 @@
</div></div>
</div>
</details>
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
même sujet. Les trois modes sont des LIGNES de cette carte (elles
étaient des cartes dans une carte). -->
<div class="slist">
<!-- Moteur fourni par l'image Docker : aucun des trois modes ne s'applique
(rien à installer, et l'image n'embarque ni cmake ni compilateur). -->
<div id="lc-provided" class="rowbody" style="display:none">
<div style="font-size:12px;line-height:1.5">Moteur <b>fourni par l'image Docker</b> — llama.cpp officiel, déjà compilé.
<div class="muted" style="margin-top:4px">Rien à installer ni à mettre à jour ici : le moteur suit l'image.
Pour en changer (version épinglée, CPU, Vulkan), reconstruis l'image avec le build-arg <code>LLAMACPP_IMAGE</code>.</div>
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
</div>
</div>
<!-- La pastille « conseillé » n'est PAS écrite ici : le bon choix dépend de
la machine (voir lcRenderReco). Sur Linux + NVIDIA le précompilé ne
donne que du Vulkan — llama.cpp ne publie aucun binaire CUDA Linux —
alors que compiler donne du CUDA natif. -->
<div class="lc-modes">
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')">
<div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco" id="lc-reco-fast" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-fast">binaire officiel téléchargé tel quel, prêt en ~2 min</div>
<div class="lc-mode-state" id="lc-fast-state"></div>
</div>
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')">
<div class="lc-mode-t">llama.cpp compilé <span class="lc-reco" id="lc-reco-opt" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-opt">compilé ici pour votre machine — plus rapide, plus long à installer</div>
<div class="lc-mode-state" id="lc-opt-state"></div>
</div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
<div class="lc-mode-t">llama.cpp personnalisé</div>
<div class="lc-mode-d">un fork de llama.cpp pour modèles spéciaux (quant ternaire…)</div>
<div class="lc-mode-state" id="lc-custom-state"></div>
</div>
</div>
<div id="lc-job" class="rowbody" style="display:none">
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
<div class="lc-logbar" onclick="lcToggleLog()"><span id="lc-log-caret">▸</span> détails</div>
<pre id="lc-log" hidden></pre>
</div>
</div>
<!-- Ces trois boutons pilotent le service qui fait tourner llama-server :
leur place est ici, avec le moteur, et pas dans une section « Service »
à part qui ne contenait qu'eux. -->
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).</span></span></div>
<div class="slist"><div class="rowbody">
<div class="row" style="margin:0">
<button onclick="act('start')">démarrer</button>
<button onclick="act('restart')">redémarrer</button>
<button onclick="act('stop')">arrêter</button>
</div>
</div></div>
</details>
<details id="upd-details"><summary><span class="stitle">Actions</span></summary>
<div class="slist"><div class="rowbody">
<!-- Ligne 1 : mise à jour + export du fil. Depuis la 0.8 la conversation vit
+1 -1
View File
@@ -491,7 +491,7 @@ async function loadAll(){
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
// hauteurs réservées en place pour toujours.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
}
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
@@ -49,6 +49,11 @@ function addMsg(role, text){
el.querySelector('.label').onclick=()=>toggleCollapse(el);
} else {
el.innerHTML='<span class="label">'+role+'</span><div class="body"></div>';
// user / assistant portent un avatar et un nom (voir 17-identity.js) ;
// reasoning et tool gardent leur libellé technique.
if((role==='user'||role==='assistant') && typeof paintLabel==='function'){
paintLabel(el.querySelector('.label'), role);
}
}
el.querySelector('.body').textContent=text;
chatEl().appendChild(el);
+79
View File
@@ -0,0 +1,79 @@
// Identité — prénom et avatars affichés en tête des bulles du chat.
//
// Les valeurs vivent dans les préférences SERVEUR (comme le thème) : elles
// suivent donc d'un appareil à l'autre, au lieu de rester dans le localStorage
// d'un seul navigateur. La liste d'emojis est celle que sert le serveur — pas
// une seconde copie côté client, qui divergerait à la première modification.
let IDENT = {name:'', user:'🙂', ai:'🦊'};
let AVATARS = [];
// applyIdentity redessine les libellés des bulles DÉJÀ affichées : changer son
// prénom doit se voir tout de suite, sans recharger la page.
function applyIdentity(){
document.querySelectorAll('#chat .msg.user > .label, #chat .msg.assistant > .label')
.forEach(l => paintLabel(l, l.parentElement.classList.contains('user') ? 'user' : 'assistant'));
}
// paintLabel remplit un libellé : avatar + nom. Le prénom vient de
// l'utilisateur, donc textContent — jamais innerHTML.
function paintLabel(label, role){
if(!label) return;
label.textContent = '';
const av = document.createElement('i'); av.className = 'av';
av.textContent = role === 'user' ? (IDENT.user || '🙂') : (IDENT.ai || '🦊');
const who = document.createElement('span'); who.className = 'who';
who.textContent = role === 'user' ? (IDENT.name || 'toi') : 'Loki';
label.appendChild(av); label.appendChild(who);
}
async function loadIdentity(){
let r;
try{ r = await jget('/api/prefs'); }catch(_){ return; }
const p = r.prefs || {};
AVATARS = r.avatars || [];
IDENT = {
name: p.user_name || '',
user: p.avatar_user || '🙂',
ai: p.avatar_ai || '🦊',
};
const inp = document.getElementById('p-username');
if(inp && document.activeElement !== inp) inp.value = IDENT.name;
renderAvatarPicker('p-avatar-user', 'user');
renderAvatarPicker('p-avatar-ai', 'ai');
applyIdentity();
}
function renderAvatarPicker(elID, which){
const box = document.getElementById(elID);
if(!box) return;
box.innerHTML = '';
for(const e of AVATARS){
const b = document.createElement('button');
b.type = 'button'; b.textContent = e;
if(e === IDENT[which]) b.classList.add('sel');
b.onclick = () => pickAvatar(which, e);
box.appendChild(b);
}
}
async function pickAvatar(which, emoji){
IDENT[which] = emoji;
renderAvatarPicker(which === 'user' ? 'p-avatar-user' : 'p-avatar-ai', which);
applyIdentity();
try{ await jpost('/api/prefs', which === 'user' ? {avatar_user:emoji} : {avatar_ai:emoji}); }
catch(_){ toast('erreur réseau'); }
}
// Saisie du prénom : on n'écrit pas au serveur à chaque frappe.
let identTimer = null;
function saveIdentity(){
const inp = document.getElementById('p-username');
if(!inp) return;
IDENT.name = inp.value.trim();
applyIdentity();
clearTimeout(identTimer);
identTimer = setTimeout(async () => {
try{ await jpost('/api/prefs', {user_name: IDENT.name}); }catch(_){ toast('erreur réseau'); }
}, 500);
}
+18 -1
View File
@@ -104,7 +104,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
/* Logo : « Loki » dessiné en pixels (SVG 4x5 par lettre, trait d'un module). Le « A »
reste un bloc à part : il apparaît en glissant quand le mode agent est actif. */
#brand{display:inline-flex;align-items:center}
#brand svg{height:10px;width:auto;display:block;shape-rendering:crispEdges;fill:currentColor}
/* 17px : le logo pixel-art était minuscule à 10px, illisible sur un écran dense.
Multiple impair volontaire — la grille du SVG (5 px de haut) reste nette. */
#brand svg{height:17px;width:auto;display:block;shape-rendering:crispEdges;fill:currentColor}
#brand-word{display:inline-flex;color:var(--accent)}
#ver{font-size:10px;font-weight:400;color:var(--dim);margin-left:7px;letter-spacing:0;align-self:flex-end;line-height:1;position:relative;top:1px}
.sidehead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}
@@ -120,6 +122,21 @@ h1{margin:0;font-size:18px;color:var(--accent)}
.preset:hover .conv-acts,.preset:focus-within .conv-acts{opacity:1}
.conv-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer}
.conv-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
/* Séparateur de groupe dans la barre latérale : sépare les réglages d'IA des
réglages d'application, sans imbriquer un second niveau de repli (illisible
dans une colonne aussi étroite). */
.side-sep{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--dim);
margin:18px 0 8px;padding-top:12px;border-top:1px solid var(--border)}
/* Choix d'avatar : une grille de pastilles emoji, celle retenue encadrée. */
.avpick{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
.avpick button{font-size:15px;line-height:1;padding:5px 6px;background:var(--panel);
border:1px solid var(--border);border-radius:8px;cursor:pointer}
.avpick button:hover{border-color:var(--accent)}
.avpick button.sel{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 15%,transparent)}
/* Avatar + nom en tête de bulle : l'emoji garde sa taille propre, le nom hérite
du style de .label (petites capitales) mais sans forcer la casse du prénom. */
.msg .label .av{font-style:normal;font-size:13px;margin-right:5px;vertical-align:-1px}
.msg .label .who{text-transform:none;letter-spacing:.04em;font-size:11px}
/* pastille d'état service, en haut à droite de la barre latérale */
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
+53 -1
View File
@@ -25,6 +25,50 @@ var webPrefsAllowed = map[string]map[string]bool{
"hide_stats": {"0": true, "1": true},
}
// avatarChoices : les emojis proposés pour toi et pour Loki. Liste FERMÉE —
// n'accepter qu'un jeu connu évite de stocker une chaîne arbitraire qui finirait
// affichée dans chaque bulle du chat.
var avatarChoices = []string{
"🙂", "😎", "🧑", "👤", "🦊", "🐱", "🐼", "🦉",
"🚀", "⚡", "🌙", "🔥", "🌿", "🎯", "🧠", "🤖",
}
// webPrefsText : préférences à valeur libre, avec un nettoyage propre à chacune.
// Renvoie la valeur retenue et si elle est acceptable.
var webPrefsText = map[string]func(string) (string, bool){
// Prénom affiché dans le chat : une ligne, sans caractères de contrôle, et
// borné en RUNES (24 « é » ne doivent pas compter double).
"user_name": func(v string) (string, bool) {
v = strings.TrimSpace(strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, v))
if r := []rune(v); len(r) > 24 {
v = strings.TrimSpace(string(r[:24]))
}
return v, true // vide = revenir au libellé par défaut
},
"avatar_user": avatarPref,
"avatar_ai": avatarPref,
}
// avatarPref n'accepte qu'un emoji de la liste fermée (ou le vide, qui rétablit
// le défaut).
func avatarPref(v string) (string, bool) {
v = strings.TrimSpace(v)
if v == "" {
return "", true
}
for _, a := range avatarChoices {
if a == v {
return v, true
}
}
return "", false
}
// loadWebPrefs lit les préférences enregistrées (map vide si aucune).
func loadWebPrefs() map[string]string {
out := map[string]string{}
@@ -40,6 +84,12 @@ func saveWebPrefs(in map[string]string) (map[string]string, error) {
defer webPrefsMu.Unlock()
prefs := loadWebPrefs()
for k, v := range in {
if clean, ok := webPrefsText[k]; ok {
if val, good := clean(v); good {
prefs[k] = val
}
continue
}
allowed, ok := webPrefsAllowed[k]
if !ok {
continue
@@ -69,5 +119,7 @@ func handleWebPrefs(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "prefs": prefs})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "prefs": loadWebPrefs()})
// avatars : la liste fermée est servie avec les préférences, pour que l'UI
// n'ait pas sa propre copie (deux listes divergent tôt ou tard).
sendJSON(w, 200, map[string]any{"ok": true, "prefs": loadWebPrefs(), "avatars": avatarChoices})
}