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 + + + +
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;tt.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
+ + + +
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;tt.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é