diff --git a/cmd/loki/icon.ico b/cmd/loki/icon.ico
index febca2c..0d5030a 100644
Binary files a/cmd/loki/icon.ico and b/cmd/loki/icon.ico differ
diff --git a/cmd/loki/icon.png b/cmd/loki/icon.png
index 597bd60..31c0399 100644
Binary files a/cmd/loki/icon.png and b/cmd/loki/icon.png differ
diff --git a/internal/loki/sys_brand_icon.go b/internal/loki/sys_brand_icon.go
index 0f75171..4b64058 100644
--- a/internal/loki/sys_brand_icon.go
+++ b/internal/loki/sys_brand_icon.go
@@ -2,12 +2,15 @@ package loki
// sys_brand_icon.go — l'icône de la marque Loki, rendue à la volée.
//
-// RÉPLIQUE EXACTE du favicon de l'UI web : carré à coins arrondis NOIR + « j »
+// RÉPLIQUE EXACTE du favicon de l'UI web : carré à coins arrondis SAUGE + « j »
// blanc (rects (6,3) (6,5) (4,7) sur une grille 12x12). Une seule source pour
// tous les usages — zone de notification Windows, barre de menus macOS, icône du
// .exe — pour qu'ils ne puissent plus diverger comme quand le favicon est passé
// au noir en laissant les icônes système en bleu.
//
+// La sauge est --accent de l'interface (#5E7F5A) : l'icône de l'onglet et celle
+// du bureau portent la couleur de l'app, pas un carré noir anonyme.
+//
// Aucun asset binaire à committer : tout est dessiné ici, et l'icône du .exe est
// produite par `go generate ./cmd/loki` (voir tools/gen-icon).
@@ -22,6 +25,8 @@ import (
const trayIconSize = 32
var (
+ // brandSage = --accent du thème clair. Le blanc du « j » s'y lit à 4,9:1.
+ brandSage = color.RGBA{0x5e, 0x7f, 0x5a, 0xff}
brandBlack = color.RGBA{0x00, 0x00, 0x00, 0xff}
brandWhite = color.RGBA{0xff, 0xff, 0xff, 0xff}
brandClear = color.RGBA{0, 0, 0, 0}
@@ -80,9 +85,9 @@ func encodePNG(img *image.RGBA) []byte {
return buf.Bytes()
}
-// BrandIconPNG rend l'icône de marque (noir + « j » blanc) en PNG de n pixels.
+// BrandIconPNG rend l'icône de marque (sauge + « j » blanc) en PNG de n pixels.
// Exporté pour le générateur d'icône du .exe (tools/gen-icon).
-func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandWhite)) }
+func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandSage, brandWhite)) }
// brandTemplatePNG rend la variante « template » attendue par macOS : seule la
// couche alpha compte, le système colore la forme selon le thème de la barre de
diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html
index 90672cf..f5825c2 100644
--- a/internal/loki/ui/index.html
+++ b/internal/loki/ui/index.html
@@ -12,8 +12,8 @@
-
-
+
+
@@ -2009,10 +2022,6 @@ button:active:not(:disabled){transform:translateY(1px)}
-
-
-
+
@@ -3030,6 +3039,9 @@ async function loadCfg(){
else v='llama.cpp personnalisé : '+c.BIN;
rows.push(row('MOTEUR', v, c.BIN));
}
+ // Le modèle chargé alimente aussi le sélecteur de l'en-tête : c'est la seule
+ // source qui le connaisse quand aucun preset ne correspond à la configuration.
+ if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
rows.push(row(k, v));
@@ -6774,33 +6786,63 @@ function onModelSwitch(sel){
switchTo(n, name);
}
-// Remplit le sélecteur à partir de la liste de presets déjà chargée par
-// loadPresets() — aucun appel réseau supplémentaire.
+// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
+//
+// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
+// empreinte correspond exactement à la configuration courante. Une configuration
+// écrite à la main, héritée d'une image, ou modifiée d'un cran depuis le dernier
+// preset ne correspond à AUCUN d'entre eux — et l'en-tête n'affichait alors rien
+// alors qu'un modèle était bel et bien chargé. On retombe donc sur le nom du
+// fichier de la configuration active.
+//
+// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
+// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
+// en parallèle (loadAll), aucun ordre n'est garanti.
+let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
+
function renderModelSwitch(presets, active){
+ MS_PRESETS = presets || []; MS_ACTIVE = active || null;
+ paintModelSwitch();
+}
+// Appelé par loadCfg avec la valeur MODEL de la configuration active.
+function setLoadedModel(name){
+ MS_MODEL = (name || '').trim();
+ paintModelSwitch();
+}
+// Nom lisible d'un fichier de modèle : sans dossier ni extension. Un .gguf
+// s'appelle « Qwen3-14B-Instruct-Q4_K_M.gguf » — le chemin et le suffixe
+// n'apprennent rien et mangent toute la place.
+function modelLabel(path){
+ const base = path.split(/[\\/]/).pop();
+ return base.replace(/\.gguf$/i, '');
+}
+function paintModelSwitch(){
const sel = document.getElementById('model-switch');
if(!sel) return;
+ const prev = sel.value;
sel.textContent = '';
- if(!presets || !presets.length){
+ // Ligne de tête : ce qui est RÉELLEMENT chargé, quand aucun preset ne le
+ // revendique. Value vide → onModelSwitch l'ignore, elle ne bascule rien.
+ if(!MS_ACTIVE){
const o = document.createElement('option');
- o.textContent = 'aucun preset'; o.value = '';
- sel.appendChild(o); sel.disabled = true;
- return;
+ o.value = '';
+ o.textContent = MS_MODEL ? modelLabel(MS_MODEL) : (MS_PRESETS.length ? 'aucun modèle actif' : 'aucun preset');
+ o.selected = true;
+ sel.appendChild(o);
}
- sel.disabled = false;
- presets.forEach((p, i) => {
+ MS_PRESETS.forEach((p, i) => {
const o = document.createElement('option');
o.value = String(i + 1);
o.textContent = p.name;
- if(active && p.id === active.id) o.selected = true;
+ if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
sel.appendChild(o);
});
- // Aucun preset actif (configuration modifiée à la main) : on le dit, plutôt
- // que de laisser le premier de la liste passer pour le modèle chargé.
- if(!active){
- const o = document.createElement('option');
- o.value = ''; o.textContent = 'configuration hors preset'; o.selected = true;
- sel.insertBefore(o, sel.firstChild);
- }
+ sel.disabled = !MS_PRESETS.length;
+ sel.title = MS_MODEL
+ ? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
+ : 'aucun modèle configuré';
+ // Une bascule en cours ne doit pas voir sa sélection sauter sous la souris.
+ if(prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
}
// État initial des libellés (le CSS, lui, est déjà appliqué par le ).
diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html
index 557df10..75daca3 100644
--- a/internal/loki/ui/src/index.tmpl.html
+++ b/internal/loki/ui/src/index.tmpl.html
@@ -12,8 +12,8 @@
-
-
+
+
@@ -338,10 +343,6 @@ document.documentElement.setAttribute('data-perf',localStorage.getItem('loki-per
-
-
-
+
diff --git a/internal/loki/ui/src/js/05-status.js b/internal/loki/ui/src/js/05-status.js
index 8ac15a5..de49932 100644
--- a/internal/loki/ui/src/js/05-status.js
+++ b/internal/loki/ui/src/js/05-status.js
@@ -205,6 +205,9 @@ async function loadCfg(){
else v='llama.cpp personnalisé : '+c.BIN;
rows.push(row('MOTEUR', v, c.BIN));
}
+ // Le modèle chargé alimente aussi le sélecteur de l'en-tête : c'est la seule
+ // source qui le connaisse quand aucun preset ne correspond à la configuration.
+ if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
rows.push(row(k, v));
diff --git a/internal/loki/ui/src/js/18-shell.js b/internal/loki/ui/src/js/18-shell.js
index bd1a322..f8fff11 100644
--- a/internal/loki/ui/src/js/18-shell.js
+++ b/internal/loki/ui/src/js/18-shell.js
@@ -67,33 +67,63 @@ function onModelSwitch(sel){
switchTo(n, name);
}
-// Remplit le sélecteur à partir de la liste de presets déjà chargée par
-// loadPresets() — aucun appel réseau supplémentaire.
+// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
+//
+// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
+// empreinte correspond exactement à la configuration courante. Une configuration
+// écrite à la main, héritée d'une image, ou modifiée d'un cran depuis le dernier
+// preset ne correspond à AUCUN d'entre eux — et l'en-tête n'affichait alors rien
+// alors qu'un modèle était bel et bien chargé. On retombe donc sur le nom du
+// fichier de la configuration active.
+//
+// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
+// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
+// en parallèle (loadAll), aucun ordre n'est garanti.
+let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
+
function renderModelSwitch(presets, active){
+ MS_PRESETS = presets || []; MS_ACTIVE = active || null;
+ paintModelSwitch();
+}
+// Appelé par loadCfg avec la valeur MODEL de la configuration active.
+function setLoadedModel(name){
+ MS_MODEL = (name || '').trim();
+ paintModelSwitch();
+}
+// Nom lisible d'un fichier de modèle : sans dossier ni extension. Un .gguf
+// s'appelle « Qwen3-14B-Instruct-Q4_K_M.gguf » — le chemin et le suffixe
+// n'apprennent rien et mangent toute la place.
+function modelLabel(path){
+ const base = path.split(/[\\/]/).pop();
+ return base.replace(/\.gguf$/i, '');
+}
+function paintModelSwitch(){
const sel = document.getElementById('model-switch');
if(!sel) return;
+ const prev = sel.value;
sel.textContent = '';
- if(!presets || !presets.length){
+ // Ligne de tête : ce qui est RÉELLEMENT chargé, quand aucun preset ne le
+ // revendique. Value vide → onModelSwitch l'ignore, elle ne bascule rien.
+ if(!MS_ACTIVE){
const o = document.createElement('option');
- o.textContent = 'aucun preset'; o.value = '';
- sel.appendChild(o); sel.disabled = true;
- return;
+ o.value = '';
+ o.textContent = MS_MODEL ? modelLabel(MS_MODEL) : (MS_PRESETS.length ? 'aucun modèle actif' : 'aucun preset');
+ o.selected = true;
+ sel.appendChild(o);
}
- sel.disabled = false;
- presets.forEach((p, i) => {
+ MS_PRESETS.forEach((p, i) => {
const o = document.createElement('option');
o.value = String(i + 1);
o.textContent = p.name;
- if(active && p.id === active.id) o.selected = true;
+ if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
sel.appendChild(o);
});
- // Aucun preset actif (configuration modifiée à la main) : on le dit, plutôt
- // que de laisser le premier de la liste passer pour le modèle chargé.
- if(!active){
- const o = document.createElement('option');
- o.value = ''; o.textContent = 'configuration hors preset'; o.selected = true;
- sel.insertBefore(o, sel.firstChild);
- }
+ sel.disabled = !MS_PRESETS.length;
+ sel.title = MS_MODEL
+ ? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
+ : 'aucun modèle configuré';
+ // Une bascule en cours ne doit pas voir sa sélection sauter sous la souris.
+ if(prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
}
// État initial des libellés (le CSS, lui, est déjà appliqué par le ).
diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css
index c932e3c..095eebb 100644
--- a/internal/loki/ui/src/styles.css
+++ b/internal/loki/ui/src/styles.css
@@ -35,7 +35,8 @@
--ok:#5e7f5a;--warn:#a86a2a;--err:#c0503f;--mag:#a86a2a;
--code-bg:#f4f6f5;--danger-bg:#faeae7;--warn-bg:#f7f0e5;
--add:#5e7f5a;--add-bg:rgba(94,127,90,.11);--del:#c0503f;--del-bg:rgba(192,80,63,.12);
- --shadow:0 1px 2px rgba(15,23,42,.05);--shadow-lift:0 4px 16px rgba(15,23,42,.08)}
+ --shadow:0 1px 2px rgba(15,23,42,.05);--shadow-lift:0 4px 16px rgba(15,23,42,.08);
+ --shadow-card:0 2px 10px rgba(15,23,42,.07),0 1px 3px rgba(15,23,42,.05)}
/* « Deep Dark » : fond #0F172A, cartes #1E293B. */
[data-theme="dark"]{color-scheme:dark;
--bg:#0f172a;--panel:#1e293b;--bubble:#1e293b;--border:rgba(148,163,184,.17);
@@ -46,7 +47,8 @@
--ok:#8fb08c;--warn:#d9a55f;--err:#e0705c;--mag:#d9a55f;
--code-bg:#131c2f;--danger-bg:#3a1f1c;--warn-bg:#33291b;
--add:#8fb08c;--add-bg:rgba(143,176,140,.16);--del:#e0705c;--del-bg:rgba(224,112,92,.16);
- --shadow:none;--shadow-lift:0 4px 16px rgba(0,0,0,.35)}
+ --shadow:none;--shadow-lift:0 4px 16px rgba(0,0,0,.35);
+ --shadow-card:0 2px 12px rgba(0,0,0,.30),0 1px 3px rgba(0,0,0,.22)}
/* Safari iOS peint un rectangle gris/noir sur TOUT élément touché (bouton, lien,
résumé de section, bulle repliable…). C'est un retour tactile par défaut, hors
de notre charte et impossible à styler : on le coupe. Nos états :active/:focus
@@ -1570,12 +1572,12 @@ button:active:not(:disabled){transform:translateY(1px)}
nu flotte sans ancrage, et la frontière entre deux réponses se perd. */
#chat{padding-top:22px}
#chat .msg.assistant{background:var(--bubble);border:1px solid var(--border);
- border-radius:16px;padding:14px 18px;max-width:100%;box-shadow:var(--shadow)}
+ border-radius:16px;padding:14px 18px;max-width:100%;box-shadow:var(--shadow-card)}
#chat .msg.assistant.errmsg{background:var(--danger-bg);border-color:color-mix(in srgb,var(--err) 45%,transparent)}
/* Question : aplat de sauge, coin bas-droit rentré. --on-accent garantit le
contraste dans les DEUX variantes. */
#chat .msg.user{background:var(--accent);color:var(--on-accent);border-radius:18px 18px 6px 18px;
- padding:11px 16px;max-width:75%}
+ padding:11px 16px;max-width:75%;box-shadow:var(--shadow-card)}
#chat .msg.user .label{color:var(--on-accent);opacity:.75}
#chat .msg.user .body a{color:var(--on-accent);text-decoration:underline}
#chat .msg.user code{background:rgba(255,255,255,.18);color:inherit}
@@ -1592,13 +1594,16 @@ button:active:not(:disabled){transform:translateY(1px)}
#send,#stop{width:34px;height:34px;min-height:34px;padding:0;border-radius:999px;
display:inline-flex;align-items:center;justify-content:center;flex:none}
#send svg,#stop svg{display:block}
-#send:hover:not(:disabled){opacity:1;transform:none;background:color-mix(in srgb,var(--accent) 85%,var(--text))}
+#send{background:transparent;border-color:transparent;color:var(--accent)}
+#send:hover:not(:disabled){opacity:1;transform:none;background:var(--accent-bg);color:var(--accent)}
#send:active:not(:disabled){transform:scale(.92)}
-#stop:hover{opacity:1;background:color-mix(in srgb,var(--err) 85%,var(--text))}
+#send:disabled{background:transparent;color:var(--dim)}
+#stop{background:transparent;border-color:transparent;color:var(--err)}
+#stop:hover{opacity:1;background:var(--danger-bg);color:var(--err)}
/* Les outils de la saisie (joindre, fichiers, envoyer) montent DANS la pilule,
à droite du texte : c'est là qu'on les cherche, et le pied de carte redevient
ce qu'il doit être — une jauge de contexte, rien d'autre. */
-#inputbar{align-items:flex-end;padding:8px 8px 8px 18px;gap:4px;box-shadow:var(--shadow)}
+#inputbar{align-items:flex-end;padding:8px 8px 8px 18px;gap:4px;box-shadow:var(--shadow-card)}
#composer:focus-within #inputbar,#composer:focus-within #ctxmeta{border-color:var(--accent-soft)}
#inputbar #attach,#inputbar #files-btn{width:34px;height:34px;min-height:34px;border-radius:999px;
align-self:center}
@@ -1670,3 +1675,6 @@ button:active:not(:disabled){transform:translateY(1px)}
garde-fou de largeur (contrairement à .pe-row-c) : une valeur très longue —
un chemin de destination, par exemple — débordait sous l'intitulé. */
.pe-row>.pe-selc{max-width:62%}
+/* Marque et attribution du fork empilées, pastille d'état à leur droite. */
+.sidebrand{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
+.sidebrand .brand-fork{margin:0;font-size:10px;line-height:1.3}