Presets : glisser-déposer, pastilles contexte et capacités, prompt système dans l'éditeur

Repris d'AJEAN et adapté à Loki :
- liste réordonnable par glisser-déposer (SortableJS 1.15.6, MIT), ordre
  gardé côté serveur (/api/presets/order) et suivi par le sélecteur de
  l'en-tête ; le clic de fin de glissement ne bascule pas de preset
- pastille de contexte (32K, 128K, 40K…) et pastille « capacités » : œil
  quand le preset lit les images (MMPROJ, API externe déclarée multimodale,
  Strata), ampoule + niveau quand il raisonne
- éditeur : prompt système DE CE preset (rangé en base, sysprompt:<id>),
  projecteur vision sur le CPU (--no-mmproj-offload, visible seulement avec
  un mmproj)
- les projecteurs mmproj ne sont plus proposés comme modèle (éditeur et
  sélecteur de l'en-tête)
- l'aide « ? » reste survolable sur un réglage grisé

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-07 09:36:59 +02:00
1 parent 127013b32b
commit ecff7de5a3
11 files changed
+412 -27

No files matched your search

+63 -1
View File
@@ -113,7 +113,69 @@ func ListPresets() ([]Preset, error) {
}) })
} }
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil return applyPresetOrder(out), nil
}
// presetOrderKey : ordre d'affichage choisi par glisser-déposer dans l'UI
// (repris d'AJEAN v0.10.9), une liste d'IDs persistée en base.
const presetOrderKey = "preset_order"
func loadPresetOrder() []string {
var ids []string
getJSON(bkState, presetOrderKey, &ids)
return ids
}
func savePresetOrder(ids []string) error { return putJSON(bkState, presetOrderKey, ids) }
// applyPresetOrder réordonne `list` (déjà trié par nom) selon l'ordre stocké :
// d'abord les presets classés dans l'ordre choisi, puis les non classés
// (fraîchement créés) dans leur ordre alphabétique. Tri STABLE.
func applyPresetOrder(list []Preset) []Preset {
order := loadPresetOrder()
if len(order) == 0 {
return list
}
pos := map[string]int{}
for i, id := range order {
pos[id] = i
}
sort.SliceStable(list, func(i, j int) bool {
pi, oki := pos[list[i].ID]
pj, okj := pos[list[j].ID]
if oki && okj {
return pi < pj
}
if oki != okj {
return oki // un preset classé passe avant un non classé
}
return false // deux non classés : l'ordre alphabétique reste
})
return list
}
// presetCtx : la taille de contexte d'un preset, formatée pour la pastille de
// la liste (« 32K », « 128K », « 1M »). CTX absent = 32768, le défaut de serve.
func presetCtx(content string) string {
raw := strings.TrimSpace(parseEnv(content)["CTX"])
if raw == "" {
raw = "32768"
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return ""
}
// Multiple de 1024 (32768, 131072…) : écrit comme on le pense, « 32K »,
// « 128K » ; sinon décimal, comme on le tape (40000 → « 40K »).
switch {
case n >= 1024 && n%1024 == 0 && n < 1<<20:
return strconv.Itoa(n/1024) + "K"
case n >= 1000000:
return strings.TrimSuffix(strconv.FormatFloat(float64(n)/1000000, 'f', 1, 64), ".0") + "M"
case n >= 1000:
return strconv.Itoa((n+500)/1000) + "K"
}
return strconv.Itoa(n)
} }
// uniquePresetID derives a unique filename id from a display name, appending // uniquePresetID derives a unique filename id from a display name, appending
@@ -0,0 +1,60 @@
package loki
import (
"os"
"path/filepath"
"testing"
)
// Ordre choisi par glisser-déposer : les presets classés d'abord, dans cet
// ordre ; les nouveaux (non classés) ensuite, par nom.
func TestPresetOrder(t *testing.T) {
testHome(t)
_ = os.MkdirAll(presetsDir(), 0o755)
for _, id := range []string{"alpha", "bravo", "charlie", "delta"} {
if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte("# NAME="+id+"\nCTX=4096\n"), 0o644); err != nil {
t.Fatal(err)
}
}
ids := func() []string {
list, err := ListPresets()
if err != nil {
t.Fatal(err)
}
var out []string
for _, p := range list {
out = append(out, p.ID)
}
return out
}
if got := ids(); got[0] != "alpha" || got[3] != "delta" {
t.Fatalf("sans ordre enregistré, tri par nom attendu : %v", got)
}
if err := savePresetOrder([]string{"charlie", "alpha"}); err != nil {
t.Fatal(err)
}
want := []string{"charlie", "alpha", "bravo", "delta"}
got := ids()
for i := range want {
if got[i] != want[i] {
t.Fatalf("ordre %v, attendu %v", got, want)
}
}
}
func TestPresetCtx(t *testing.T) {
cases := map[string]string{
"": "32K", // défaut de serve : 32768
"CTX=40000\n": "40K",
"CTX=\"131072\"\n": "128K",
"CTX=1000000\n": "1M",
"CTX=1500000\n": "1.5M",
"CTX=512\n": "512",
"CTX=abc\n": "",
}
for in, want := range cases {
if got := presetCtx(in); got != want {
t.Errorf("presetCtx(%q) = %q, attendu %q", in, got, want)
}
}
}
+121 -11
View File
@@ -205,7 +205,7 @@ option,optgroup{background:var(--panel);color:var(--text)}
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */ /* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
@media (min-width:721px){ @media (min-width:721px){
.side{width:380px} .side{width:380px}
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag{font-size:11px;padding:2px 9px;border-radius:4px} .preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag,.preset-meta .ctag,.preset-meta .captag{font-size:11px;padding:2px 9px;border-radius:4px}
.preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg,.preset-meta .xtag svg{width:11px;height:11px} .preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg,.preset-meta .xtag svg{width:11px;height:11px}
} }
h1{margin:0;font-size:18px;color:var(--accent)} h1{margin:0;font-size:18px;color:var(--accent)}
@@ -301,6 +301,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
(étiquette comprise), plus sur la carte seule. */ (étiquette comprise), plus sur la carte seule. */
.gated-group{margin:0 0 10px;transition:opacity .15s} .gated-group{margin:0 0 10px;transition:opacity .15s}
.gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)} .gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)}
/* L'aide « ? » reste survolable sur un réglage grisé : c'est justement là qu'on
veut savoir à quoi il sert (repris d'AJEAN, #104). */
.gated-group.gated .help{pointer-events:auto}
.switch{position:relative;display:inline-block;width:36px;height:20px;flex:none} .switch{position:relative;display:inline-block;width:36px;height:20px;flex:none}
.switch input{position:absolute;opacity:0;width:0;height:0} .switch input{position:absolute;opacity:0;width:0;height:0}
/* Piste décochée : un gris franc (pas --border), sinon le pavé blanc ne se /* Piste décochée : un gris franc (pas --border), sinon le pavé blanc ne se
@@ -398,6 +401,17 @@ button:disabled{opacity:.5;cursor:not-allowed}
.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap} .preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap}
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase} .qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase}
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em} .rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
.ctag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
/* Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
(ampoule + niveau). Icônes en em : la bulle a la hauteur des autres tags. */
.captag{flex-shrink:0;display:inline-flex;align-items:center;gap:4px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
.captag::before{content:"\200b";margin-right:-4px}
.captag svg{display:block;width:1.3em;height:1.3em}
/* Réordonnancement des presets (SortableJS) : clone plein qui suit le curseur,
emplacement cible discret. */
.preset-drag{cursor:grabbing;background:var(--row-bg-on);border:none;border-radius:6px;box-shadow:0 6px 18px rgba(0,0,0,.35);outline:none;opacity:1!important}
.preset-ghost{opacity:.35}
.preset-chosen{cursor:grabbing}
/* Preset externe : le seul tag qui prend la couleur d'accent. Il ne décrit pas /* Preset externe : le seul tag qui prend la couleur d'accent. Il ne décrit pas
un réglage mais une DESTINATION — la réponse part sur internet, pas sur la un réglage mais une DESTINATION — la réponse part sur internet, pas sur la
carte graphique d'à côté, et ça mérite de se voir du premier coup d'œil. carte graphique d'à côté, et ça mérite de se voir du premier coup d'œil.
@@ -3446,6 +3460,11 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span> <span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span> <span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
</div> </div>
<!-- Repris d'AJEAN v0.10.3 : n'apparaît qu'avec un projecteur choisi. -->
<div class="pe-row" id="s-mmproj-cpu-row" style="display:none">
<span class="pe-row-l">Projecteur vision sur le CPU<span class="pe-sub">garde le mmproj hors du GPU — libère de la VRAM, images lues plus lentement (--no-mmproj-offload)</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mmproj-cpu" onchange="eaToggleFlag('--no-mmproj-offload', this.checked)"><span class="pe-sl"></span></label></span>
</div>
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous <!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
reste là : c'est le mode expert (dépôt privé, fichier exotique, reste là : c'est le mode expert (dépôt privé, fichier exotique,
lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et
@@ -3726,6 +3745,18 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div> </div>
</div> </div>
<!-- Prompt système DE CE preset (repris d'AJEAN v0.13.0) : il bascule avec
le preset. Rangé en base (sysprompt:<id>), pas dans le .env qui ne
porte qu'une ligne par clé. Vide = le prompt global s'applique. -->
<div id="m-sys-row" class="pe-group" style="display:none">
<div class="pe-gh">Prompt système</div>
<div class="pe-list">
<div class="pe-row stack">
<textarea id="m-sysprompt" class="pe-inp" rows="4" spellcheck="false" placeholder="(optionnel) consigne propre à ce preset — vide = prompt système général"></textarea>
</div>
</div>
</div>
<div id="m-raw" class="pe-group"> <div id="m-raw" class="pe-group">
<div class="pe-gh" id="m-raw-head">Configuration</div> <div class="pe-gh" id="m-raw-head">Configuration</div>
<div class="pe-list"> <div class="pe-list">
@@ -3842,7 +3873,8 @@ async function loadSys(){
if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('loki.sys', d.text || ''); } if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('loki.sys', d.text || ''); }
}catch(e){} }catch(e){}
} }
// ===== Apparence ============================================================= /*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?e(Object(i),!0).forEach(function(t){var e,n;e=o,t=i[n=t],n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(i)):e(Object(i)).forEach(function(t){Object.defineProperty(o,t,Object.getOwnPropertyDescriptor(i,t))})}return o}function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)Object.prototype.hasOwnProperty.call(o,n)&&(t[n]=o[n])}return t}).apply(this,arguments)}function i(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};for(var n,o={},i=Object.keys(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||(o[n]=t[n]);return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function r(t){return function(t){if(Array.isArray(t))return l(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return l(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),s=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),c=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),d={capture:!1,passive:!1};function h(t,e,n){t.addEventListener(e,n,!y&&d)}function p(t,e,n){t.removeEventListener(e,n,!y&&d)}function f(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBoundingClientRect();a-=h.top+parseInt(R(i,"border-top-width")),l-=h.left+parseInt(R(i,"border-left-width")),s=a+r.height,c=l+r.width;break}}while(i=i.parentNode);return o&&t!==window&&(o=(e=b(i||t))&&e.a,t=e&&e.d,e&&(s=(a/=t)+(u/=t),c=(l/=o)+(d/=o))),{top:a,left:l,bottom:s,right:c,width:d,height:u}}}function Y(t,e,n){for(var o=M(tLine truncated
// Un seul thème, en deux variantes : claire (par défaut) et sombre. La variante // Un seul thème, en deux variantes : claire (par défaut) et sombre. La variante
// est portée par l'attribut data-theme de <html> ('' ou 'dark') ; tout le reste // est portée par l'attribut data-theme de <html> ('' ou 'dark') ; tout le reste
// est du CSS (voir la section « Palette »). Persistée dans localStorage pour // est du CSS (voir la section « Palette »). Persistée dans localStorage pour
@@ -4587,7 +4619,9 @@ async function loadPresets(){
const row=document.createElement('div'); const row=document.createElement('div');
const pend = !x.active && pendingPreset===i+1; const pend = !x.active && pendingPreset===i+1;
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':''); row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
row.onclick=()=>switchTo(i+1, x.name, x.id); row.dataset.id = x.id;
// Fin d'un glisser-déposer : le clic qui le termine ne bascule pas le preset.
row.onclick=()=>{ if(presetJustDragged) return; switchTo(i+1, x.name, x.id); };
const info=document.createElement('div'); info.className='preset-info'; const info=document.createElement('div'); info.className='preset-info';
const nm=document.createElement('div'); nm.className='preset-name'; const nm=document.createElement('div'); nm.className='preset-name';
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe // Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
@@ -4615,12 +4649,27 @@ async function loadPresets(){
q.textContent=x.quant; q.title='quantization'; q.textContent=x.quant; q.title='quantization';
meta.appendChild(q); meta.appendChild(q);
} }
if(x.reasoning){ if(x.ctx && !x.external){
const rt=document.createElement('span'); rt.className='rtag'; const c=document.createElement('span'); c.className='ctag';
rt.title='raisonnement actif ('+x.reasoning+')'; c.title='taille de contexte'; c.textContent=x.ctx;
rt.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V18h6v-1.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z"/></svg>'; meta.appendChild(c);
rt.appendChild(document.createTextNode(x.reasoning)); }
meta.appendChild(rt); // Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
// (ampoule + niveau), regroupés en un seul tag.
if(x.vision || x.reasoning){
const cap=document.createElement('span'); cap.className='captag';
const tip=[];
if(x.vision){
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>');
tip.push('lit les images');
}
if(x.reasoning){
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 21h4"/><path d="M12 3a6 6 0 0 0-4 10.5c.6.6 1 1.4 1 2.5h6c0-1.1.4-1.9 1-2.5A6 6 0 0 0 12 3z"/></svg>');
cap.appendChild(document.createTextNode(x.reasoning));
tip.push('raisonnement actif ('+x.reasoning+')');
}
cap.title=tip.join(' · ');
meta.appendChild(cap);
} }
if(x.bench){ if(x.bench){
const bt=document.createElement('span'); bt.className='btag'; const bt=document.createElement('span'); bt.className='btag';
@@ -4645,6 +4694,40 @@ async function loadPresets(){
row.appendChild(info); row.appendChild(edit); row.appendChild(info); row.appendChild(edit);
cont.appendChild(row); cont.appendChild(row);
}); });
initPresetSortable(cont);
}
// ─── Réordonnancement des presets (SortableJS, repris d'AJEAN) ───────────────
// La ligne entière se déplace, sans poignée. L'ordre est gardé côté serveur
// (/api/presets/order) : il vaut aussi pour le sélecteur de l'en-tête.
let presetJustDragged = false;
let presetSortable = null;
function initPresetSortable(cont){
if(typeof Sortable==='undefined') return; // lib absente : la liste reste simple
if(presetSortable) return; // une instance, sur le conteneur qui persiste
presetSortable = Sortable.create(cont, {
animation: 160,
easing: 'cubic-bezier(.2,.7,.3,1)',
// Clone géré par Sortable (et non l'image native, semi-transparente), posé
// sur <body> : dans la modale (ancêtre transformé), il apparaissait loin
// du curseur.
forceFallback: true,
fallbackTolerance: 3,
fallbackOnBody: true,
// Sur mobile, il faut MAINTENIR l'appui : un simple défilement attrapait un preset.
delay: 220,
delayOnTouchOnly: true,
touchStartThreshold: 6,
ghostClass: 'preset-ghost',
chosenClass: 'preset-chosen',
dragClass: 'preset-drag',
onStart(){ presetJustDragged = true; },
onEnd(){
setTimeout(()=>{ presetJustDragged = false; }, 60);
const ids=[...cont.children].map(r=>r.dataset.id).filter(Boolean);
// Re-rendu une fois l'ordre persisté : les positions (pendingPreset) suivent.
jpost('/api/presets/order', {ids}).then(()=>loadPresets()).catch(()=>{});
},
});
} }
// « Mode agent » = accès machine + skills réunis en un seul interrupteur. // « Mode agent » = accès machine + skills réunis en un seul interrupteur.
// Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ». // Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ».
@@ -5313,6 +5396,10 @@ async function openItem(kind, key){
const modelRow = document.getElementById('m-model-row'); const modelRow = document.getElementById('m-model-row');
const settingsRow = document.getElementById('m-settings-row'); const settingsRow = document.getElementById('m-settings-row');
const samplingRow = document.getElementById('m-sampling-row'); const samplingRow = document.getElementById('m-sampling-row');
const sysRow = document.getElementById('m-sys-row');
if(sysRow) sysRow.style.display = kind === 'preset' ? '' : 'none';
const sysTa = document.getElementById('m-sysprompt');
if(sysTa) sysTa.value = '';
const rawHead = document.getElementById('m-raw-head'); const rawHead = document.getElementById('m-raw-head');
const rawToggle = document.getElementById('m-raw-toggle'); const rawToggle = document.getElementById('m-raw-toggle');
const rawBody = document.getElementById('m-raw-body'); const rawBody = document.getElementById('m-raw-body');
@@ -5371,6 +5458,7 @@ async function openItem(kind, key){
document.getElementById('m-name').value = display; document.getElementById('m-name').value = display;
document.getElementById('m-content').value = d.content || ''; document.getElementById('m-content').value = d.content || '';
if(kind === 'preset'){ if(kind === 'preset'){
if(sysTa) sysTa.value = d.sysprompt || '';
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée. // Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
document.getElementById('m-quant').value = currentQuantInTextarea(); document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings(); populateSettings();
@@ -5452,6 +5540,10 @@ async function populateModelPicker(){
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre). // regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
const groups = []; const groups = [];
for(const m of (list||[])){ for(const m of (list||[])){
// Projecteurs vision : ils ont leur propre sélecteur (Vision) et ne se
// lancent pas comme modèle (repris d'AJEAN v0.16.2). Gardé seulement s'il
// est DÉJÀ le MODEL du preset, pour ne pas masquer une erreur de saisie.
if(isMmprojName(m.name) && m !== hit) continue;
let g = groups.find(x => x.dir === m.dir); let g = groups.find(x => x.dir === m.dir);
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); } if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
g.items.push(m); g.items.push(m);
@@ -5787,6 +5879,21 @@ function onPickMmproj(){
// et on centralise le choix dans la clé MMPROJ. // et on centralise le choix dans la clé MMPROJ.
eaSetValued('--mmproj', ''); eaSetValued('--mmproj', '');
cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value); cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value);
syncMmprojCpuRow();
}
// « Projecteur sur le CPU » n'a de sens qu'avec un mmproj chargé : la ligne est
// masquée sans vision. Source de vérité = la config, pas le select (rempli
// APRÈS cette synchro à l'ouverture du preset). Sans projecteur, le drapeau
// resté dans EXTRA_ARGS est retiré.
function syncMmprojCpuRow(){
const row = document.getElementById('s-mmproj-cpu-row');
if(!row) return;
const on = !!currentMmprojInTextarea();
row.style.display = on ? '' : 'none';
if(!on && eaHasFlag('--no-mmproj-offload')){
eaToggleFlag('--no-mmproj-offload', false);
const c = document.getElementById('s-mmproj-cpu'); if(c) c.checked = false;
}
} }
// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la // Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la
// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là — // convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là —
@@ -6242,6 +6349,8 @@ function populateSettings(){
} }
} }
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn'))); chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
chk('s-mmproj-cpu', eaHasFlag('--no-mmproj-offload'));
syncMmprojCpuRow();
const lm = eaLoadMode(); const lm = eaLoadMode();
set('s-loadmode', lm); set('s-loadmode', lm);
syncLoadModeSub(lm); syncLoadModeSub(lm);
@@ -6576,7 +6685,7 @@ async function saveItem(){
// Presets: keyed by id (filename); duplicate display names are allowed. // Presets: keyed by id (filename); duplicate display names are allowed.
// Skills: keyed by name, rename via `old`. // Skills: keyed by name, rename via `old`.
const payload = editingKind==='preset' const payload = editingKind==='preset'
? {id: editingKey, name, content} ? {id: editingKey, name, content, sysprompt: (document.getElementById('m-sysprompt')||{}).value || ''}
: {name, old: editingKey, content}; : {name, old: editingKey, content};
const r = await jpost(K.saveUrl, payload); const r = await jpost(K.saveUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
@@ -10670,7 +10779,8 @@ function renderModelSwitch(presets, active){
async function loadModelSwitchFiles(){ async function loadModelSwitchFiles(){
try{ try{
const r = await jget('/api/models'); const r = await jget('/api/models');
MS_FILES = Array.isArray(r) ? r : []; // Projecteurs vision exclus : choisis comme modèle, le moteur ne démarre pas.
MS_FILES = (Array.isArray(r) ? r : []).filter(m => !/mmproj/i.test(String(m.name||'')));
}catch(_){ return; } }catch(_){ return; }
paintModelSwitch(); paintModelSwitch();
} }
+17
View File
@@ -1189,6 +1189,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span> <span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span> <span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
</div> </div>
<!-- Repris d'AJEAN v0.10.3 : n'apparaît qu'avec un projecteur choisi. -->
<div class="pe-row" id="s-mmproj-cpu-row" style="display:none">
<span class="pe-row-l">Projecteur vision sur le CPU<span class="pe-sub">garde le mmproj hors du GPU — libère de la VRAM, images lues plus lentement (--no-mmproj-offload)</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mmproj-cpu" onchange="eaToggleFlag('--no-mmproj-offload', this.checked)"><span class="pe-sl"></span></label></span>
</div>
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous <!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
reste là : c'est le mode expert (dépôt privé, fichier exotique, reste là : c'est le mode expert (dépôt privé, fichier exotique,
lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et
@@ -1469,6 +1474,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div> </div>
</div> </div>
<!-- Prompt système DE CE preset (repris d'AJEAN v0.13.0) : il bascule avec
le preset. Rangé en base (sysprompt:<id>), pas dans le .env qui ne
porte qu'une ligne par clé. Vide = le prompt global s'applique. -->
<div id="m-sys-row" class="pe-group" style="display:none">
<div class="pe-gh">Prompt système</div>
<div class="pe-list">
<div class="pe-row stack">
<textarea id="m-sysprompt" class="pe-inp" rows="4" spellcheck="false" placeholder="(optionnel) consigne propre à ce preset — vide = prompt système général"></textarea>
</div>
</div>
</div>
<div id="m-raw" class="pe-group"> <div id="m-raw" class="pe-group">
<div class="pe-gh" id="m-raw-head">Configuration</div> <div class="pe-gh" id="m-raw-head">Configuration</div>
<div class="pe-list"> <div class="pe-list">
+2
View File
@@ -0,0 +1,2 @@
/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?e(Object(i),!0).forEach(function(t){var e,n;e=o,t=i[n=t],n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(i)):e(Object(i)).forEach(function(t){Object.defineProperty(o,t,Object.getOwnPropertyDescriptor(i,t))})}return o}function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)Object.prototype.hasOwnProperty.call(o,n)&&(t[n]=o[n])}return t}).apply(this,arguments)}function i(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};for(var n,o={},i=Object.keys(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||(o[n]=t[n]);return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function r(t){return function(t){if(Array.isArray(t))return l(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return l(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),s=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),c=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),d={capture:!1,passive:!1};function h(t,e,n){t.addEventListener(e,n,!y&&d)}function p(t,e,n){t.removeEventListener(e,n,!y&&d)}function f(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBoundingClientRect();a-=h.top+parseInt(R(i,"border-top-width")),l-=h.left+parseInt(R(i,"border-left-width")),s=a+r.height,c=l+r.width;break}}while(i=i.parentNode);return o&&t!==window&&(o=(e=b(i||t))&&e.a,t=e&&e.d,e&&(s=(a/=t)+(u/=t),c=(l/=o)+(d/=o))),{top:a,left:l,bottom:s,right:c,width:d,height:u}}}function Y(t,e,n){for(var o=M(tLine truncated
+58 -7
View File
@@ -60,7 +60,9 @@ async function loadPresets(){
const row=document.createElement('div'); const row=document.createElement('div');
const pend = !x.active && pendingPreset===i+1; const pend = !x.active && pendingPreset===i+1;
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':''); row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
row.onclick=()=>switchTo(i+1, x.name, x.id); row.dataset.id = x.id;
// Fin d'un glisser-déposer : le clic qui le termine ne bascule pas le preset.
row.onclick=()=>{ if(presetJustDragged) return; switchTo(i+1, x.name, x.id); };
const info=document.createElement('div'); info.className='preset-info'; const info=document.createElement('div'); info.className='preset-info';
const nm=document.createElement('div'); nm.className='preset-name'; const nm=document.createElement('div'); nm.className='preset-name';
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe // Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
@@ -88,12 +90,27 @@ async function loadPresets(){
q.textContent=x.quant; q.title='quantization'; q.textContent=x.quant; q.title='quantization';
meta.appendChild(q); meta.appendChild(q);
} }
if(x.reasoning){ if(x.ctx && !x.external){
const rt=document.createElement('span'); rt.className='rtag'; const c=document.createElement('span'); c.className='ctag';
rt.title='raisonnement actif ('+x.reasoning+')'; c.title='taille de contexte'; c.textContent=x.ctx;
rt.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V18h6v-1.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z"/></svg>'; meta.appendChild(c);
rt.appendChild(document.createTextNode(x.reasoning)); }
meta.appendChild(rt); // Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
// (ampoule + niveau), regroupés en un seul tag.
if(x.vision || x.reasoning){
const cap=document.createElement('span'); cap.className='captag';
const tip=[];
if(x.vision){
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>');
tip.push('lit les images');
}
if(x.reasoning){
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 21h4"/><path d="M12 3a6 6 0 0 0-4 10.5c.6.6 1 1.4 1 2.5h6c0-1.1.4-1.9 1-2.5A6 6 0 0 0 12 3z"/></svg>');
cap.appendChild(document.createTextNode(x.reasoning));
tip.push('raisonnement actif ('+x.reasoning+')');
}
cap.title=tip.join(' · ');
meta.appendChild(cap);
} }
if(x.bench){ if(x.bench){
const bt=document.createElement('span'); bt.className='btag'; const bt=document.createElement('span'); bt.className='btag';
@@ -118,6 +135,40 @@ async function loadPresets(){
row.appendChild(info); row.appendChild(edit); row.appendChild(info); row.appendChild(edit);
cont.appendChild(row); cont.appendChild(row);
}); });
initPresetSortable(cont);
}
// ─── Réordonnancement des presets (SortableJS, repris d'AJEAN) ───────────────
// La ligne entière se déplace, sans poignée. L'ordre est gardé côté serveur
// (/api/presets/order) : il vaut aussi pour le sélecteur de l'en-tête.
let presetJustDragged = false;
let presetSortable = null;
function initPresetSortable(cont){
if(typeof Sortable==='undefined') return; // lib absente : la liste reste simple
if(presetSortable) return; // une instance, sur le conteneur qui persiste
presetSortable = Sortable.create(cont, {
animation: 160,
easing: 'cubic-bezier(.2,.7,.3,1)',
// Clone géré par Sortable (et non l'image native, semi-transparente), posé
// sur <body> : dans la modale (ancêtre transformé), il apparaissait loin
// du curseur.
forceFallback: true,
fallbackTolerance: 3,
fallbackOnBody: true,
// Sur mobile, il faut MAINTENIR l'appui : un simple défilement attrapait un preset.
delay: 220,
delayOnTouchOnly: true,
touchStartThreshold: 6,
ghostClass: 'preset-ghost',
chosenClass: 'preset-chosen',
dragClass: 'preset-drag',
onStart(){ presetJustDragged = true; },
onEnd(){
setTimeout(()=>{ presetJustDragged = false; }, 60);
const ids=[...cont.children].map(r=>r.dataset.id).filter(Boolean);
// Re-rendu une fois l'ordre persisté : les positions (pendingPreset) suivent.
jpost('/api/presets/order', {ids}).then(()=>loadPresets()).catch(()=>{});
},
});
} }
// « Mode agent » = accès machine + skills réunis en un seul interrupteur. // « Mode agent » = accès machine + skills réunis en un seul interrupteur.
// Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ». // Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ».
+27 -1
View File
@@ -158,6 +158,10 @@ async function openItem(kind, key){
const modelRow = document.getElementById('m-model-row'); const modelRow = document.getElementById('m-model-row');
const settingsRow = document.getElementById('m-settings-row'); const settingsRow = document.getElementById('m-settings-row');
const samplingRow = document.getElementById('m-sampling-row'); const samplingRow = document.getElementById('m-sampling-row');
const sysRow = document.getElementById('m-sys-row');
if(sysRow) sysRow.style.display = kind === 'preset' ? '' : 'none';
const sysTa = document.getElementById('m-sysprompt');
if(sysTa) sysTa.value = '';
const rawHead = document.getElementById('m-raw-head'); const rawHead = document.getElementById('m-raw-head');
const rawToggle = document.getElementById('m-raw-toggle'); const rawToggle = document.getElementById('m-raw-toggle');
const rawBody = document.getElementById('m-raw-body'); const rawBody = document.getElementById('m-raw-body');
@@ -216,6 +220,7 @@ async function openItem(kind, key){
document.getElementById('m-name').value = display; document.getElementById('m-name').value = display;
document.getElementById('m-content').value = d.content || ''; document.getElementById('m-content').value = d.content || '';
if(kind === 'preset'){ if(kind === 'preset'){
if(sysTa) sysTa.value = d.sysprompt || '';
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée. // Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
document.getElementById('m-quant').value = currentQuantInTextarea(); document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings(); populateSettings();
@@ -297,6 +302,10 @@ async function populateModelPicker(){
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre). // regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
const groups = []; const groups = [];
for(const m of (list||[])){ for(const m of (list||[])){
// Projecteurs vision : ils ont leur propre sélecteur (Vision) et ne se
// lancent pas comme modèle (repris d'AJEAN v0.16.2). Gardé seulement s'il
// est DÉJÀ le MODEL du preset, pour ne pas masquer une erreur de saisie.
if(isMmprojName(m.name) && m !== hit) continue;
let g = groups.find(x => x.dir === m.dir); let g = groups.find(x => x.dir === m.dir);
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); } if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
g.items.push(m); g.items.push(m);
@@ -632,6 +641,21 @@ function onPickMmproj(){
// et on centralise le choix dans la clé MMPROJ. // et on centralise le choix dans la clé MMPROJ.
eaSetValued('--mmproj', ''); eaSetValued('--mmproj', '');
cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value); cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value);
syncMmprojCpuRow();
}
// « Projecteur sur le CPU » n'a de sens qu'avec un mmproj chargé : la ligne est
// masquée sans vision. Source de vérité = la config, pas le select (rempli
// APRÈS cette synchro à l'ouverture du preset). Sans projecteur, le drapeau
// resté dans EXTRA_ARGS est retiré.
function syncMmprojCpuRow(){
const row = document.getElementById('s-mmproj-cpu-row');
if(!row) return;
const on = !!currentMmprojInTextarea();
row.style.display = on ? '' : 'none';
if(!on && eaHasFlag('--no-mmproj-offload')){
eaToggleFlag('--no-mmproj-offload', false);
const c = document.getElementById('s-mmproj-cpu'); if(c) c.checked = false;
}
} }
// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la // Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la
// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là — // convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là —
@@ -1087,6 +1111,8 @@ function populateSettings(){
} }
} }
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn'))); chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
chk('s-mmproj-cpu', eaHasFlag('--no-mmproj-offload'));
syncMmprojCpuRow();
const lm = eaLoadMode(); const lm = eaLoadMode();
set('s-loadmode', lm); set('s-loadmode', lm);
syncLoadModeSub(lm); syncLoadModeSub(lm);
@@ -1421,7 +1447,7 @@ async function saveItem(){
// Presets: keyed by id (filename); duplicate display names are allowed. // Presets: keyed by id (filename); duplicate display names are allowed.
// Skills: keyed by name, rename via `old`. // Skills: keyed by name, rename via `old`.
const payload = editingKind==='preset' const payload = editingKind==='preset'
? {id: editingKey, name, content} ? {id: editingKey, name, content, sysprompt: (document.getElementById('m-sysprompt')||{}).value || ''}
: {name, old: editingKey, content}; : {name, old: editingKey, content};
const r = await jpost(K.saveUrl, payload); const r = await jpost(K.saveUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
+2 -1
View File
@@ -108,7 +108,8 @@ function renderModelSwitch(presets, active){
async function loadModelSwitchFiles(){ async function loadModelSwitchFiles(){
try{ try{
const r = await jget('/api/models'); const r = await jget('/api/models');
MS_FILES = Array.isArray(r) ? r : []; // Projecteurs vision exclus : choisis comme modèle, le moteur ne démarre pas.
MS_FILES = (Array.isArray(r) ? r : []).filter(m => !/mmproj/i.test(String(m.name||'')));
}catch(_){ return; } }catch(_){ return; }
paintModelSwitch(); paintModelSwitch();
} }
+15 -1
View File
@@ -170,7 +170,7 @@ option,optgroup{background:var(--panel);color:var(--text)}
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */ /* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
@media (min-width:721px){ @media (min-width:721px){
.side{width:380px} .side{width:380px}
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag{font-size:11px;padding:2px 9px;border-radius:4px} .preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag,.preset-meta .ctag,.preset-meta .captag{font-size:11px;padding:2px 9px;border-radius:4px}
.preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg,.preset-meta .xtag svg{width:11px;height:11px} .preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg,.preset-meta .xtag svg{width:11px;height:11px}
} }
h1{margin:0;font-size:18px;color:var(--accent)} h1{margin:0;font-size:18px;color:var(--accent)}
@@ -266,6 +266,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
(étiquette comprise), plus sur la carte seule. */ (étiquette comprise), plus sur la carte seule. */
.gated-group{margin:0 0 10px;transition:opacity .15s} .gated-group{margin:0 0 10px;transition:opacity .15s}
.gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)} .gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)}
/* L'aide « ? » reste survolable sur un réglage grisé : c'est justement là qu'on
veut savoir à quoi il sert (repris d'AJEAN, #104). */
.gated-group.gated .help{pointer-events:auto}
.switch{position:relative;display:inline-block;width:36px;height:20px;flex:none} .switch{position:relative;display:inline-block;width:36px;height:20px;flex:none}
.switch input{position:absolute;opacity:0;width:0;height:0} .switch input{position:absolute;opacity:0;width:0;height:0}
/* Piste décochée : un gris franc (pas --border), sinon le pavé blanc ne se /* Piste décochée : un gris franc (pas --border), sinon le pavé blanc ne se
@@ -363,6 +366,17 @@ button:disabled{opacity:.5;cursor:not-allowed}
.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap} .preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap}
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase} .qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase}
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em} .rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
.ctag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
/* Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
(ampoule + niveau). Icônes en em : la bulle a la hauteur des autres tags. */
.captag{flex-shrink:0;display:inline-flex;align-items:center;gap:4px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
.captag::before{content:"\200b";margin-right:-4px}
.captag svg{display:block;width:1.3em;height:1.3em}
/* Réordonnancement des presets (SortableJS) : clone plein qui suit le curseur,
emplacement cible discret. */
.preset-drag{cursor:grabbing;background:var(--row-bg-on);border:none;border-radius:6px;box-shadow:0 6px 18px rgba(0,0,0,.35);outline:none;opacity:1!important}
.preset-ghost{opacity:.35}
.preset-chosen{cursor:grabbing}
/* Preset externe : le seul tag qui prend la couleur d'accent. Il ne décrit pas /* Preset externe : le seul tag qui prend la couleur d'accent. Il ne décrit pas
un réglage mais une DESTINATION — la réponse part sur internet, pas sur la un réglage mais une DESTINATION — la réponse part sur internet, pas sur la
carte graphique d'à côté, et ça mérite de se voir du premier coup d'œil. carte graphique d'à côté, et ça mérite de se voir du premier coup d'œil.
+46 -5
View File
@@ -441,9 +441,20 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
if m := strings.TrimSpace(cfg[extKeyModel]); m != "" { if m := strings.TrimSpace(cfg[extKeyModel]); m != "" {
item["model"] = m item["model"] = m
} }
if strings.TrimSpace(cfg[extKeyVision]) == "1" {
item["vision"] = true
}
out = append(out, item) out = append(out, item)
continue continue
} }
// Pastilles de la liste : taille de contexte, et l'œil quand le preset
// charge un projecteur vision (repris d'AJEAN v0.10.1 / v0.10.9).
if c := presetCtx(content); c != "" {
item["ctx"] = c
}
if strings.TrimSpace(parseEnv(content)["MMPROJ"]) != "" {
item["vision"] = true
}
// Strata : réglages dans sa propre fenêtre, pas l'éditeur llama.cpp. // Strata : réglages dans sa propre fenêtre, pas l'éditeur llama.cpp.
if cfg := parseEnv(content); isStrataConfig(cfg) { if cfg := parseEnv(content); isStrataConfig(cfg) {
item["strata"] = true item["strata"] = true
@@ -481,6 +492,26 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, out) sendJSON(w, 200, out)
} }
// handlePresetsOrder (POST {ids}) : l'ordre de la liste choisi par glisser-déposer.
func handlePresetsOrder(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendJSON(w, 405, map[string]any{"ok": false, "error": "POST attendu"})
return
}
var req struct {
IDs []string `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := savePresetOrder(req.IDs); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
func handlePreset(w http.ResponseWriter, r *http.Request) { func handlePreset(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id")) id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" { if id == "" {
@@ -492,16 +523,20 @@ func handlePreset(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 404, map[string]any{"error": "not found"}) sendJSON(w, 404, map[string]any{"error": "not found"})
return return
} }
sendJSON(w, 200, map[string]any{"id": id, "name": presetDisplayName(content, id), "content": content}) sendJSON(w, 200, map[string]any{"id": id, "name": presetDisplayName(content, id), "content": content,
"sysprompt": getStr(bkState, sysPromptKeyPrefix+id)})
} }
// presetSaveReq is the preset editor payload. `id` identifies an existing // presetSaveReq is the preset editor payload. `id` identifies an existing
// preset to update ("" creates a new one); `name` is the display name. // preset to update ("" creates a new one); `name` is the display name.
// SysPrompt : le prompt système DE CE preset, édité dans l'éditeur (repris
// d'AJEAN v0.13.0) ; nil = champ absent, on n'y touche pas.
type presetSaveReq struct { type presetSaveReq struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Content string `json:"content"` Content string `json:"content"`
DeleteModel bool `json:"deleteModel"` DeleteModel bool `json:"deleteModel"`
SysPrompt *string `json:"sysprompt,omitempty"`
} }
// saveReq is the skill editor payload (skills keep name-as-identity + rename). // saveReq is the skill editor payload (skills keep name-as-identity + rename).
@@ -604,6 +639,12 @@ func handlePresetSave(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return return
} }
if req.SysPrompt != nil {
if err := putStr(bkState, sysPromptKeyPrefix+newID, strings.TrimSpace(*req.SysPrompt)); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
}
if applied { if applied {
fmt.Printf("%s config.env <- %s (preset actif modifié)\n", green("[ok]"), newID) fmt.Printf("%s config.env <- %s (preset actif modifié)\n", green("[ok]"), newID)
fmt.Println(dim("[info] redémarrage du service...")) fmt.Println(dim("[info] redémarrage du service..."))
+1
View File
@@ -262,6 +262,7 @@ func newWebMux() *http.ServeMux {
api("/api/strata/install", handleStrataInstall) // job : paquet figé + installeur de Strata + preset (suivi par /api/llamacpp/job) api("/api/strata/install", handleStrataInstall) // job : paquet figé + installeur de Strata + preset (suivi par /api/llamacpp/job)
api("/api/strata/settings", handleStrataSettings) // réglages d'un modèle Strata installé (relance s'il est actif) api("/api/strata/settings", handleStrataSettings) // réglages d'un modèle Strata installé (relance s'il est actif)
api("/api/presets", handlePresets) api("/api/presets", handlePresets)
api("/api/presets/order", handlePresetsOrder) // ordre de la liste (glisser-déposer)
api("/api/preset", handlePreset) api("/api/preset", handlePreset)
api("/api/preset/save", handlePresetSave) api("/api/preset/save", handlePresetSave)
api("/api/preset/cacheram", handlePresetCacheRAM) // aperçu de --cache-ram pour le preset édité api("/api/preset/cacheram", handlePresetCacheRAM) // aperçu de --cache-ram pour le preset édité