diff --git a/internal/loki/backend_presets.go b/internal/loki/backend_presets.go
index 6c76c5b..ee15ac1 100644
--- a/internal/loki/backend_presets.go
+++ b/internal/loki/backend_presets.go
@@ -113,7 +113,69 @@ func ListPresets() ([]Preset, error) {
})
}
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
diff --git a/internal/loki/backend_presets_order_test.go b/internal/loki/backend_presets_order_test.go
new file mode 100644
index 0000000..050c592
--- /dev/null
+++ b/internal/loki/backend_presets_order_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html
index ada3851..abb3b27 100644
--- a/internal/loki/ui/index.html
+++ b/internal/loki/ui/index.html
@@ -205,7 +205,7 @@ option,optgroup{background:var(--panel);color:var(--text)}
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
@media (min-width:721px){
.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}
}
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. */
.gated-group{margin:0 0 10px;transition:opacity .15s}
.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 input{position:absolute;opacity:0;width:0;height:0}
/* 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}
.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}
+.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
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.
@@ -3446,6 +3460,11 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Visionprojecteur mmproj
+
+
+ Projecteur vision sur le CPUgarde le mmproj hors du GPU — libère de la VRAM, images lues plus lentement (--no-mmproj-offload)
+
+
+
+
Configuration
@@ -3842,7 +3873,8 @@ async function loadSys(){
if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('loki.sys', d.text || ''); }
}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
t.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===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=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientY ('' ou 'dark') ; tout le reste
// 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 pend = !x.active && pendingPreset===i+1;
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 nm=document.createElement('div'); nm.className='preset-name';
// 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';
meta.appendChild(q);
}
- if(x.reasoning){
- const rt=document.createElement('span'); rt.className='rtag';
- rt.title='raisonnement actif ('+x.reasoning+')';
- rt.innerHTML='';
- rt.appendChild(document.createTextNode(x.reasoning));
- meta.appendChild(rt);
+ if(x.ctx && !x.external){
+ const c=document.createElement('span'); c.className='ctag';
+ c.title='taille de contexte'; c.textContent=x.ctx;
+ meta.appendChild(c);
+ }
+ // 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','');
+ tip.push('lit les images');
+ }
+ if(x.reasoning){
+ cap.insertAdjacentHTML('beforeend','');
+ cap.appendChild(document.createTextNode(x.reasoning));
+ tip.push('raisonnement actif ('+x.reasoning+')');
+ }
+ cap.title=tip.join(' · ');
+ meta.appendChild(cap);
}
if(x.bench){
const bt=document.createElement('span'); bt.className='btag';
@@ -4645,6 +4694,40 @@ async function loadPresets(){
row.appendChild(info); row.appendChild(edit);
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 : 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.
// 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 settingsRow = document.getElementById('m-settings-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 rawToggle = document.getElementById('m-raw-toggle');
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-content').value = d.content || '';
if(kind === 'preset'){
+ if(sysTa) sysTa.value = d.sysprompt || '';
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
@@ -5452,6 +5540,10 @@ async function populateModelPicker(){
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
const groups = [];
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);
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
g.items.push(m);
@@ -5787,6 +5879,21 @@ function onPickMmproj(){
// et on centralise le choix dans la clé MMPROJ.
eaSetValued('--mmproj', '');
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
// 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-mmproj-cpu', eaHasFlag('--no-mmproj-offload'));
+ syncMmprojCpuRow();
const lm = eaLoadMode();
set('s-loadmode', lm);
syncLoadModeSub(lm);
@@ -6576,7 +6685,7 @@ async function saveItem(){
// Presets: keyed by id (filename); duplicate display names are allowed.
// Skills: keyed by name, rename via `old`.
const payload = editingKind==='preset'
- ? {id: editingKey, name, content}
+ ? {id: editingKey, name, content, sysprompt: (document.getElementById('m-sysprompt')||{}).value || ''}
: {name, old: editingKey, content};
const r = await jpost(K.saveUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
@@ -10670,7 +10779,8 @@ function renderModelSwitch(presets, active){
async function loadModelSwitchFiles(){
try{
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; }
paintModelSwitch();
}
diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html
index 8c73f55..59fb1f4 100644
--- a/internal/loki/ui/src/index.tmpl.html
+++ b/internal/loki/ui/src/index.tmpl.html
@@ -1189,6 +1189,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
Visionprojecteur mmproj
+
+
+ Projecteur vision sur le CPUgarde le mmproj hors du GPU — libère de la VRAM, images lues plus lentement (--no-mmproj-offload)
+
+
+
+
Configuration
diff --git a/internal/loki/ui/src/js/00-sortable.min.js b/internal/loki/ui/src/js/00-sortable.min.js
new file mode 100644
index 0000000..95423a6
--- /dev/null
+++ b/internal/loki/ui/src/js/00-sortable.min.js
@@ -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
t.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===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=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientYswitchTo(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 nm=document.createElement('div'); nm.className='preset-name';
// 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';
meta.appendChild(q);
}
- if(x.reasoning){
- const rt=document.createElement('span'); rt.className='rtag';
- rt.title='raisonnement actif ('+x.reasoning+')';
- rt.innerHTML='';
- rt.appendChild(document.createTextNode(x.reasoning));
- meta.appendChild(rt);
+ if(x.ctx && !x.external){
+ const c=document.createElement('span'); c.className='ctag';
+ c.title='taille de contexte'; c.textContent=x.ctx;
+ meta.appendChild(c);
+ }
+ // 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','');
+ tip.push('lit les images');
+ }
+ if(x.reasoning){
+ cap.insertAdjacentHTML('beforeend','');
+ cap.appendChild(document.createTextNode(x.reasoning));
+ tip.push('raisonnement actif ('+x.reasoning+')');
+ }
+ cap.title=tip.join(' · ');
+ meta.appendChild(cap);
}
if(x.bench){
const bt=document.createElement('span'); bt.className='btag';
@@ -118,6 +135,40 @@ async function loadPresets(){
row.appendChild(info); row.appendChild(edit);
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 : 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.
// Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ».
diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js
index 8b5f696..7c1ba6c 100644
--- a/internal/loki/ui/src/js/07-models.js
+++ b/internal/loki/ui/src/js/07-models.js
@@ -158,6 +158,10 @@ async function openItem(kind, key){
const modelRow = document.getElementById('m-model-row');
const settingsRow = document.getElementById('m-settings-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 rawToggle = document.getElementById('m-raw-toggle');
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-content').value = d.content || '';
if(kind === 'preset'){
+ if(sysTa) sysTa.value = d.sysprompt || '';
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
@@ -297,6 +302,10 @@ async function populateModelPicker(){
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
const groups = [];
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);
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
g.items.push(m);
@@ -632,6 +641,21 @@ function onPickMmproj(){
// et on centralise le choix dans la clé MMPROJ.
eaSetValued('--mmproj', '');
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
// 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-mmproj-cpu', eaHasFlag('--no-mmproj-offload'));
+ syncMmprojCpuRow();
const lm = eaLoadMode();
set('s-loadmode', lm);
syncLoadModeSub(lm);
@@ -1421,7 +1447,7 @@ async function saveItem(){
// Presets: keyed by id (filename); duplicate display names are allowed.
// Skills: keyed by name, rename via `old`.
const payload = editingKind==='preset'
- ? {id: editingKey, name, content}
+ ? {id: editingKey, name, content, sysprompt: (document.getElementById('m-sysprompt')||{}).value || ''}
: {name, old: editingKey, content};
const r = await jpost(K.saveUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
diff --git a/internal/loki/ui/src/js/18-shell.js b/internal/loki/ui/src/js/18-shell.js
index 306f2cd..5c931ce 100644
--- a/internal/loki/ui/src/js/18-shell.js
+++ b/internal/loki/ui/src/js/18-shell.js
@@ -108,7 +108,8 @@ function renderModelSwitch(presets, active){
async function loadModelSwitchFiles(){
try{
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; }
paintModelSwitch();
}
diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css
index 8d3b57d..8d37912 100644
--- a/internal/loki/ui/src/styles.css
+++ b/internal/loki/ui/src/styles.css
@@ -170,7 +170,7 @@ option,optgroup{background:var(--panel);color:var(--text)}
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
@media (min-width:721px){
.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}
}
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. */
.gated-group{margin:0 0 10px;transition:opacity .15s}
.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 input{position:absolute;opacity:0;width:0;height:0}
/* 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}
.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}
+.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
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.
diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go
index f44fb03..97e9b3f 100644
--- a/internal/loki/web_api.go
+++ b/internal/loki/web_api.go
@@ -441,9 +441,20 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
if m := strings.TrimSpace(cfg[extKeyModel]); m != "" {
item["model"] = m
}
+ if strings.TrimSpace(cfg[extKeyVision]) == "1" {
+ item["vision"] = true
+ }
out = append(out, item)
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.
if cfg := parseEnv(content); isStrataConfig(cfg) {
item["strata"] = true
@@ -481,6 +492,26 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
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) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
@@ -492,16 +523,20 @@ func handlePreset(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 404, map[string]any{"error": "not found"})
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
// 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 {
- ID string `json:"id"`
- Name string `json:"name"`
- Content string `json:"content"`
- DeleteModel bool `json:"deleteModel"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Content string `json:"content"`
+ DeleteModel bool `json:"deleteModel"`
+ SysPrompt *string `json:"sysprompt,omitempty"`
}
// 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()})
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 {
fmt.Printf("%s config.env <- %s (preset actif modifié)\n", green("[ok]"), newID)
fmt.Println(dim("[info] redémarrage du service..."))
diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go
index cfb6de9..4c32bb6 100644
--- a/internal/loki/web_server.go
+++ b/internal/loki/web_server.go
@@ -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/settings", handleStrataSettings) // réglages d'un modèle Strata installé (relance s'il est actif)
api("/api/presets", handlePresets)
+ api("/api/presets/order", handlePresetsOrder) // ordre de la liste (glisser-déposer)
api("/api/preset", handlePreset)
api("/api/preset/save", handlePresetSave)
api("/api/preset/cacheram", handlePresetCacheRAM) // aperçu de --cache-ram pour le preset édité