diff --git a/src/client/app.css b/src/client/app.css index 000a083..9173a8b 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -381,31 +381,74 @@ body:has(.connstat[data-state="offline"]) .banner { display: block; } .queue .qtext { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* ---- settings ---------------------------------------------------------- */ -.settings { max-width: 860px; margin: 0 auto; padding: 28px 24px 60px; width: 100%; } -.settings h1 { font-size: 20px; margin: 0 0 18px; } -.settings .shead { font-size: 15px; font-weight: 600; margin: 0 0 4px; color: var(--ink); } -.settings .lede { color: var(--ink-muted); font-size: 14px; margin: 0 0 24px; } -.settings .back { font-family: var(--mono); font-size: 12px; color: var(--ink-muted); text-decoration: none; } -.settings .back:hover { color: var(--ink); } -.mtable { width: 100%; border-collapse: collapse; font-size: 14px; } -.mtable th { text-align: left; font-family: var(--mono); font-size: 11px; text-transform: uppercase; - letter-spacing: .06em; color: var(--ink-faint); font-weight: 500; padding: 8px 10px; border-bottom: 1px solid var(--rule); } -.mtable td { padding: 10px; border-bottom: 1px solid var(--rule); vertical-align: middle; } -.mtable tr:hover td { background: var(--bg-sunk); } -.mtable .ref { font-family: var(--mono); font-size: 12px; color: var(--ink-muted); } -.mtable .cap { font-family: var(--mono); font-size: 11px; color: var(--ink-faint); } -.mtable input[type="text"] { font: inherit; font-size: 13px; color: var(--ink); background: var(--bg); - border: 1px solid var(--rule-strong); border-radius: var(--radius-sm); padding: 4px 8px; width: 140px; } -.mtable input[type="number"] { font: inherit; font-size: 13px; color: var(--ink); background: var(--bg); - border: 1px solid var(--rule-strong); border-radius: var(--radius-sm); padding: 4px 6px; width: 56px; } -.mtable .prov { font-weight: 600; font-size: 13px; padding-top: 20px; color: var(--ink-muted); } -.toggle { appearance: none; width: 34px; height: 20px; border-radius: var(--radius-pill); background: var(--rule-strong); - position: relative; cursor: pointer; transition: background .12s ease; flex: none; } +.setpage { max-width: 760px; margin: 0 auto; padding: 14px 16px 60px; width: 100%; } +.setpage .shead { font-size: 15px; font-weight: 600; margin: 6px 0 4px; color: var(--ink); } +.setpage .lede { color: var(--ink-muted); font-size: 13.5px; margin: 0 0 18px; max-width: 62ch; } +.setpage code { font-family: var(--mono); font-size: 0.9em; background: var(--bg-sunk); + border: 1px solid var(--rule); border-radius: var(--radius-xs); padding: 0.05em 0.35em; } + +.seclabel { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; + color: var(--ink-faint); font-weight: 500; margin: 22px 0 8px; } +.seclabel:first-child { margin-top: 2px; } +.modellist { display: flex; flex-direction: column; gap: 6px; } +.modelempty { padding: 14px; font-size: 13px; color: var(--ink-faint); + border: 1px dashed var(--rule); border-radius: var(--radius-md); } +.modelrow { display: flex; align-items: center; gap: 11px; padding: 9px 12px 9px 3px; + background: var(--bg-raise); border: 1px solid var(--rule); border-radius: var(--radius-md); + user-select: none; } +.modelrow .mrename { user-select: text; } +.modelrow .dragspace { width: 26px; flex: none; } +.modelrow.selected { border-color: color-mix(in srgb, var(--accent) 55%, var(--rule-strong)); + background: color-mix(in srgb, var(--accent) 8%, var(--bg-raise)); } +.modelrow.off { opacity: .5; } +/* While dragging: the grabbed row stays in flow (visibility-hidden, so the + browser doesn't abort the drag) and becomes the single dashed drop slot; the + rest of the selection leaves the flow entirely. The drag image (a stacked + card + count) carries the visual. */ +.modelrow.dragging { display: none; } +.modelrow.dropslot { background: color-mix(in srgb, var(--accent) 8%, var(--bg-sunk)); + border: 1px dashed color-mix(in srgb, var(--accent) 55%, var(--rule-strong)); } +.modelrow.dropslot > * { visibility: hidden; } + +/* Off-screen drag image: the lead card, backed by faint offset cards and a + count badge when several rows move together (macOS-Finder style). */ +.dragghost { position: fixed; top: -1000px; left: -1000px; width: 236px; height: 64px; pointer-events: none; } +.dragghost .ghostback, .dragghost .ghostcard { position: absolute; box-sizing: border-box; + width: 220px; height: 44px; border-radius: var(--radius-md); } +.dragghost .ghostcard { left: 0; top: 8px; display: flex; align-items: center; padding: 0 13px; + background: var(--bg-raise); border: 1px solid var(--rule-strong); box-shadow: 0 10px 24px rgba(0,0,0,.28); + font-size: 13px; font-weight: 500; color: var(--ink); white-space: nowrap; overflow: hidden; } +.dragghost .ghostback { left: 5px; top: 13px; background: var(--bg-sunk); border: 1px solid var(--rule); } +.dragghost .ghostbadge { position: absolute; left: 206px; top: 2px; height: 20px; min-width: 20px; padding: 0 6px; + box-sizing: border-box; display: flex; align-items: center; justify-content: center; + background: var(--accent); color: #fff; border-radius: 10px; font-size: 12px; font-weight: 600; + box-shadow: 0 2px 6px rgba(0,0,0,.3); } +.modellist-enabled .modelrow { cursor: grab; } +.modellist-enabled .modelrow:active { cursor: grabbing; } +.modellist-enabled .modelrow .toggle, +.modellist-enabled .modelrow .mrename { cursor: auto; } +.modelrow .drag { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 30px; + flex: none; color: var(--ink-faint); background: none; border: 0; padding: 0; cursor: grab; border-radius: var(--radius-sm); } +.modelrow .drag:hover { color: var(--ink-muted); } +.modelrow .drag svg { width: 16px; height: 16px; } +.modelmain { flex: 1; min-width: 0; } +.mname { font-size: 14px; font-weight: 500; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.mmeta { font-family: var(--mono); font-size: 11.5px; color: var(--ink-faint); margin-top: 1px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.mrename { flex: none; width: 150px; font: inherit; font-size: 13px; color: var(--ink); background: var(--bg-sunk); + border: 1px solid var(--rule-strong); border-radius: var(--radius-sm); padding: 5px 9px; } +.mrename:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 45%, var(--rule-strong)); } +.mrename::placeholder { color: var(--ink-faint); } +@media (max-width: 560px) { .mrename { width: 108px; } } + +.toggle { appearance: none; -webkit-appearance: none; width: 34px; height: 20px; border-radius: var(--radius-pill); + background: var(--rule-strong); position: relative; cursor: pointer; transition: background .12s ease; flex: none; } .toggle:checked { background: var(--accent); } .toggle::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .12s ease; } .toggle:checked::after { transform: translateX(14px); } -.saved { font-family: var(--mono); font-size: 11px; color: var(--ok); opacity: 0; transition: opacity .2s; } +.saved { flex: none; width: 32px; font-family: var(--mono); font-size: 11px; color: var(--ok); + opacity: 0; transition: opacity .2s; } .saved.show { opacity: 1; } .saved.failed { color: var(--err); } diff --git a/src/client/settings.html b/src/client/settings.html index 76784df..47d1ad9 100644 --- a/src/client/settings.html +++ b/src/client/settings.html @@ -8,13 +8,24 @@ -
- ← Back to chat -

Settings

-

Models

-

Choose which models appear in the chat picker, and give them display names. - Chat is opt-in — nothing shows until you enable it here.

-
Loading…
+
+ + +
+
+ + Settings +
+
+
+

Models

+

Turn models on to add them to the chat picker, drag the enabled ones to set their order (⌘-click to move several at once), and give them display names.

+
Loading…
+
+
+
diff --git a/src/client/settings.js b/src/client/settings.js index 93556cb..c3c5dc6 100644 --- a/src/client/settings.js +++ b/src/client/settings.js @@ -1,21 +1,45 @@ /* - * Model curation. Lists every model this deployment can run (GET /models, joined - * to catalog metadata + curation state) and lets the operator toggle chat - * visibility, set a display name, and order the picker (PATCH /models). Each - * edit is a partial PATCH — omitted fields keep their stored value. + * Model curation. Lists every model this deployment can run (GET /models) and + * lets the operator toggle chat visibility, give a display name, and set the + * picker order by dragging (each edit is a partial PATCH /models). Uses the + * shared sidebar; the drag order is written back as each model's sortOrder. */ +import { mountSidebar } from "./sidebar.js"; +import { mountDialogs } from "./confirm.js"; + (function () { "use strict"; var content = document.getElementById("content"); + var dialogs = mountDialogs(); + var sidebar = mountSidebar({ + onSelect: function (id) { window.location.href = "/?c=" + encodeURIComponent(id); }, + onNew: function () { window.location.href = "/?new=1"; }, + dialogs: dialogs, + reload: loadSidebar, + }); + async function loadSidebar() { + try { + var r = await fetch("/api/conversations"); + sidebar.render(((await r.json()).conversations) || []); + } catch (_) { sidebar.render([]); } + } - function group(models) { - // Bucket by provider (the ref prefix) for a readable table. - var by = {}; - models.forEach(function (m) { - var prov = m.ref.split("/")[0]; - (by[prov] = by[prov] || []).push(m); - }); - return by; + var GRIP = ''; + var byRef = Object.create(null); + var allModels = []; + function providerOf(ref) { return ref.split("/")[0]; } + function seclabel(text) { var d = document.createElement("div"); d.className = "seclabel"; d.textContent = text; return d; } + + function fmtCtx(n) { + if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M"; + return Math.round(n / 1000) + "k"; + } + function cap(m) { + var c = []; + if (m.contextWindow) c.push(fmtCtx(m.contextWindow) + " ctx"); + if (m.reasoningLevels && m.reasoningLevels.length) c.push("reasoning"); + if (m.supportsImages) c.push("images"); + return c.join(" · "); } // Flash the row's inline indicator: "saved" (green) or "failed" (red). @@ -26,7 +50,7 @@ el.classList.add("show"); setTimeout(function () { el.classList.remove("show"); }, ok ? 900 : 2200); } - async function patch(ref, field, value, savedEl) { + async function patchRaw(ref, field, value) { var body = { ref: ref }; body[field] = value; try { @@ -35,97 +59,244 @@ headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); - if (!res.ok) throw new Error(String(res.status)); - flash(savedEl, true); - } catch (e) { - flash(savedEl, false); + return res.ok; + } catch (e) { return false; } + } + async function patch(ref, field, value, savedEl) { + flash(savedEl, await patchRaw(ref, field, value)); + } + + function modelCard(m, draggable) { + var row = document.createElement("div"); + row.className = "modelrow" + (m.visible ? "" : " off"); + row.dataset.ref = m.ref; + + if (draggable) { + var handle = document.createElement("button"); + handle.type = "button"; handle.className = "drag"; + handle.setAttribute("aria-label", "Drag to reorder"); handle.innerHTML = GRIP; + row.appendChild(handle); + } else { + var spacer = document.createElement("span"); spacer.className = "dragspace"; + row.appendChild(spacer); } + + var toggle = document.createElement("input"); + toggle.type = "checkbox"; toggle.className = "toggle"; toggle.checked = !!m.visible; + toggle.setAttribute("aria-label", "Show in chat picker"); + + var main = document.createElement("div"); main.className = "modelmain"; + var nm = document.createElement("div"); nm.className = "mname"; nm.textContent = m.displayName || m.name; + var meta = document.createElement("div"); meta.className = "mmeta"; + meta.textContent = m.ref + (cap(m) ? " · " + cap(m) : ""); + main.appendChild(nm); main.appendChild(meta); + + var rename = document.createElement("input"); + rename.type = "text"; rename.className = "mrename"; rename.placeholder = m.name; rename.value = m.displayName || ""; + + var saved = document.createElement("span"); saved.className = "saved"; saved.textContent = "saved"; + + // Enabling/disabling moves the model between sections, so re-render on success. + toggle.addEventListener("change", async function () { + var v = toggle.checked; + if (await patchRaw(m.ref, "visible", v)) { m.visible = v; render(); } + else { toggle.checked = !v; flash(saved, false); } + }); + rename.addEventListener("change", function () { + var v = rename.value.trim(); + nm.textContent = v || m.name; + patch(m.ref, "displayName", v === "" ? null : v, saved); + }); + + row.appendChild(toggle); row.appendChild(main); row.appendChild(rename); row.appendChild(saved); + return row; } - function fmtCtx(n) { - if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M"; - return Math.round(n / 1000) + "k"; + function render() { + byRef = Object.create(null); + allModels.forEach(function (m) { byRef[m.ref] = m; }); + content.innerHTML = ""; + if (!allModels.length) { + content.innerHTML = '

No models available. Enable providers in providers.json and restart the server.

'; + return; + } + + // Enabled models: one ordered, draggable list (this order IS the picker order). + var enabled = allModels.filter(function (m) { return m.visible; }) + .sort(function (a, b) { return (a.sortOrder || 0) - (b.sortOrder || 0) || a.name.localeCompare(b.name); }); + content.appendChild(seclabel("In chat")); + var list = document.createElement("div"); list.className = "modellist modellist-enabled"; + if (enabled.length) { + enabled.forEach(function (m) { list.appendChild(modelCard(m, true)); }); + } else { + var empty = document.createElement("div"); empty.className = "modelempty"; + empty.textContent = "No models enabled yet — turn some on below."; + list.appendChild(empty); + } + content.appendChild(list); + if (enabled.length) wireDrag(list); + + // Disabled models: grouped by provider, not draggable. + var groups = Object.create(null); + allModels.filter(function (m) { return !m.visible; }).forEach(function (m) { + var p = providerOf(m.ref); + (groups[p] = groups[p] || []).push(m); + }); + Object.keys(groups).sort().forEach(function (prov) { + content.appendChild(seclabel(prov)); + var pl = document.createElement("div"); pl.className = "modellist"; + groups[prov].sort(function (a, b) { return a.name.localeCompare(b.name); }) + .forEach(function (m) { pl.appendChild(modelCard(m, false)); }); + content.appendChild(pl); + }); } - function cap(m) { - var c = []; - if (m.contextWindow) c.push(fmtCtx(m.contextWindow)); - if (m.reasoningLevels && m.reasoningLevels.length) c.push("reasoning"); - if (m.supportsImages) c.push("images"); - return c.join(" · "); + + // ---- drag-to-reorder (writes each moved model's new index as sortOrder) --- + function rowAfter(list, y) { + var rows = Array.prototype.slice.call(list.querySelectorAll(".modelrow:not(.dragging):not(.dropslot)")); + for (var i = 0; i < rows.length; i++) { + var r = rows[i].getBoundingClientRect(); + if (y < r.top + r.height / 2) return rows[i]; + } + return null; } + function commitOrder(list) { + Array.prototype.slice.call(list.querySelectorAll(".modelrow")).forEach(function (row, i) { + var m = byRef[row.dataset.ref]; + if (m && m.sortOrder !== i) { + m.sortOrder = i; + patch(row.dataset.ref, "sortOrder", i, row.querySelector(".saved")); + } + }); + } + // Clear the enabled-list selection when a click lands anywhere outside it + // (page background, a section label, the disabled rows) — standard + // file-manager behavior. Wired once; survives re-renders. + function clearAllSelection() { + Array.prototype.slice.call(document.querySelectorAll(".modelrow.selected")) + .forEach(function (r) { r.classList.remove("selected"); }); + } + document.addEventListener("mousedown", function (e) { + if (!e.target.closest(".modellist-enabled")) clearAllSelection(); + }); - function render(models) { - if (!models.length) { - content.innerHTML = '

No models available. Enable providers in providers.json ' + - 'and restart the server.

'; - return; + // While a model drag is in flight, accept dragover/drop anywhere on the page + // so releasing over the heading (or any spot outside the list) doesn't trigger + // the browser's snap-back animation. Wired once. + var dragActive = false; + document.addEventListener("dragover", function (e) { if (dragActive) e.preventDefault(); }); + document.addEventListener("drop", function (e) { if (dragActive) e.preventDefault(); }); + + // Off-screen drag image: the lead card, with faint offset cards behind it and + // a count badge when more than one row is moving together. + function buildGhost(group, lead) { + var wrap = document.createElement("div"); + wrap.className = "dragghost"; + var n = group.length; + // A single card behind the lead hints "more than one"; the badge carries + // the exact count. + if (n > 1) { var b = document.createElement("div"); b.className = "ghostback"; wrap.appendChild(b); } + var card = document.createElement("div"); card.className = "ghostcard"; + var nm = lead.querySelector(".mname"); + card.textContent = nm ? nm.textContent : "Model"; + wrap.appendChild(card); + if (n > 1) { + var badge = document.createElement("div"); badge.className = "ghostbadge"; badge.textContent = String(n); + wrap.appendChild(badge); } - var by = group(models); - var table = document.createElement("table"); - table.className = "mtable"; - table.innerHTML = "ShowModelDisplay nameOrder"; - var tbody = document.createElement("tbody"); - - Object.keys(by).sort().forEach(function (prov) { - var head = document.createElement("tr"); - head.innerHTML = '' + prov + ""; - tbody.appendChild(head); - - by[prov].forEach(function (m) { - var tr = document.createElement("tr"); - - var tdToggle = document.createElement("td"); - var toggle = document.createElement("input"); - toggle.type = "checkbox"; toggle.className = "toggle"; toggle.checked = !!m.visible; - tdToggle.appendChild(toggle); - - var tdName = document.createElement("td"); - tdName.innerHTML = '
'; - tdName.children[0].textContent = m.name; - tdName.children[1].textContent = m.ref; - - var tdDisplay = document.createElement("td"); - var nameInput = document.createElement("input"); - nameInput.type = "text"; nameInput.placeholder = m.name; - nameInput.value = m.displayName || ""; - tdDisplay.appendChild(nameInput); - - var tdOrder = document.createElement("td"); - var orderInput = document.createElement("input"); - orderInput.type = "number"; orderInput.value = m.sortOrder || 0; - tdOrder.appendChild(orderInput); - - var tdSaved = document.createElement("td"); - var saved = document.createElement("span"); - saved.className = "saved"; saved.textContent = "saved"; - var capEl = document.createElement("span"); - capEl.className = "cap"; capEl.textContent = cap(m); - tdSaved.appendChild(capEl); tdSaved.appendChild(document.createTextNode(" ")); tdSaved.appendChild(saved); - - toggle.addEventListener("change", function () { patch(m.ref, "visible", toggle.checked, saved); }); - nameInput.addEventListener("change", function () { - patch(m.ref, "displayName", nameInput.value.trim() === "" ? null : nameInput.value.trim(), saved); - }); - orderInput.addEventListener("change", function () { - patch(m.ref, "sortOrder", Number(orderInput.value) || 0, saved); - }); - - tr.appendChild(tdToggle); tr.appendChild(tdName); tr.appendChild(tdDisplay); - tr.appendChild(tdOrder); tr.appendChild(tdSaved); - tbody.appendChild(tr); - }); + return wrap; + } + + function wireDrag(list) { + var group = null, anchor = null, srcRow = null; + function rows() { return Array.prototype.slice.call(list.querySelectorAll(".modelrow:not(.dropslot)")); } + function clearSel() { rows().forEach(function (r) { r.classList.remove("selected"); }); } + + // File-manager selection: plain click selects just that row, ⌘/Ctrl-click + // toggles one, Shift-click extends a range from the anchor. Clicks on the + // row's own controls (handle, toggle, rename) are left alone. + list.addEventListener("click", function (e) { + if (e.target.closest(".toggle, .mrename")) return; + var row = e.target.closest(".modelrow"); + if (!row) { clearSel(); anchor = null; return; } + var rs = rows(); + if (e.shiftKey && anchor && rs.indexOf(anchor) !== -1) { + var a = rs.indexOf(anchor), b = rs.indexOf(row); + var lo = Math.min(a, b), hi = Math.max(a, b); + clearSel(); + for (var i = lo; i <= hi; i++) rs[i].classList.add("selected"); + } else if (e.metaKey || e.ctrlKey) { + row.classList.toggle("selected"); + anchor = row; + } else { + clearSel(); + row.classList.add("selected"); + anchor = row; + } }); - table.appendChild(tbody); - content.innerHTML = ""; - content.appendChild(table); + rows().forEach(function (row) { + // Grabbing anywhere on the card starts a drag; only the toggle and rename + // field are excluded so they stay clickable. draggable is toggled on + // press so text/clicks elsewhere behave normally until a drag begins. + row.addEventListener("mousedown", function (e) { + row.draggable = !e.target.closest(".toggle, .mrename"); + }); + row.addEventListener("mouseup", function () { row.draggable = false; }); + row.addEventListener("dragstart", function (e) { + srcRow = row; + // Dragging an unselected row makes it the sole selection first (like + // dragging an unselected file); dragging a selected row carries the + // whole selection. + if (!row.classList.contains("selected")) { clearSel(); row.classList.add("selected"); anchor = row; } + group = rows().filter(function (r) { return r.classList.contains("selected"); }); + dragActive = true; + e.dataTransfer.effectAllowed = "move"; + try { e.dataTransfer.setData("text/plain", ""); } catch (_) {} + // Stacked drag image (removed once the browser has snapshotted it). + var ghost = buildGhost(group, row); + document.body.appendChild(ghost); + try { e.dataTransfer.setDragImage(ghost, 16, 24); } catch (_) {} + setTimeout(function () { ghost.remove(); }, 0); + // The grabbed row becomes the single drop slot; the rest of the + // selection leaves the flow (display:none). Deferred a tick because + // hiding the source node synchronously inside dragstart aborts the drag. + var picked = group; + setTimeout(function () { + if (!dragActive) return; + picked.forEach(function (r) { r.classList.add(r === row ? "dropslot" : "dragging"); }); + }, 0); + }); + row.addEventListener("dragend", function () { + row.draggable = false; + dragActive = false; + if (group) { + // Gather the whole selection (in original order) at the drop slot's spot. + var ref = row.nextSibling; + while (ref && group.indexOf(ref) !== -1) ref = ref.nextSibling; + group.forEach(function (r) { + r.classList.remove("dragging"); r.classList.remove("dropslot"); + list.insertBefore(r, ref); + }); + } + group = null; + commitOrder(list); + }); + }); + list.addEventListener("dragover", function (e) { + if (!group || !srcRow) return; + e.preventDefault(); + var after = rowAfter(list, e.clientY); + if (after !== srcRow) list.insertBefore(srcRow, after); // after === null appends + }); } (async function () { + loadSidebar(); try { var res = await fetch("/api/models"); - var body = await res.json(); - render(body.models || []); + allModels = ((await res.json()).models) || []; + render(); } catch (e) { content.innerHTML = '

Failed to load models: ' + e.message + "

"; }