Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfd8418d95
|
||
|
|
db22962164
|
||
|
|
793f9c8cad
|
||
|
|
da43bc1459
|
@@ -16,6 +16,82 @@ for 1.0.0 have something to be assembled from.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 1.8.5
|
||||
|
||||
- **No more grey slivers at the ends of the tab bars.** Tab bars fade at an edge
|
||||
to show there are more tabs to scroll to. The fade was only partly hidden
|
||||
when there was nothing to scroll, so a shadow always showed at both ends. It
|
||||
was invisible on the dark theme and a grey sliver on Shire, on Administration
|
||||
→ Prompts, Settings and every other tabbed page. The fade now appears only
|
||||
on the side where tabs are actually hidden.
|
||||
|
||||
## 1.8.4
|
||||
|
||||
Two things that kept showing up after they should have gone away.
|
||||
|
||||
- **"A new version is ready" no longer appears on a page that is already the
|
||||
new version.** After an update the toast showed up on every page, even one just
|
||||
fetched with Ctrl+Shift+R, and reloading never made it go away. It fired
|
||||
whenever a new service worker was waiting. But a page loaded after the update
|
||||
already *is* the update: pages always come from the server, and every
|
||||
stylesheet and script they name carries the release in its address. The
|
||||
worker that waits is nearly always the one from the previous release, still
|
||||
holding the tab, because a reload opens the new page before the old one goes
|
||||
away. The toast now compares the waiting worker's release with the page's
|
||||
own, so it only appears in a tab that was opened before the update. For the
|
||||
same reason, pressing Reload in one tab no longer reloads the other tabs that
|
||||
are already up to date. That matters when one of them has a reply streaming
|
||||
into it.
|
||||
|
||||
- **Switching tabs on Administration → Prompts no longer lifts the page.**
|
||||
Choosing any tab but the first pushed the whole window up by the height of
|
||||
the title bar and left a blank strip along the bottom, under the sidebar too.
|
||||
The prompt cards' hidden labels were positioned against the page instead of
|
||||
the panel. That made the page 6,771px tall behind a window that cannot scroll
|
||||
by hand, and the tab switch then scrolled it anyway. Every scrolling area now
|
||||
contains what is inside it, and a tab switch moves only the panel that
|
||||
scrolls. Administration → General on a small phone had the same leak and is
|
||||
fixed with it.
|
||||
|
||||
## 1.8.3
|
||||
|
||||
The model picker on a phone, which could not be read once a chat was open.
|
||||
|
||||
- **The model menu no longer runs off the left of the screen.** Inside a chat
|
||||
the picker sits in the middle of the top bar, with the panel buttons to its
|
||||
right, and its menu opened from the picker's right edge — so on a phone most
|
||||
of it was off the screen and every model's name was cut off. On a narrow
|
||||
screen the menu now hangs from the bar itself, edge to edge, and every name is
|
||||
whole. Wider screens are unchanged.
|
||||
|
||||
- **Opening it on a touchscreen no longer raises the keyboard.** With more than
|
||||
eight models the menu has a filter box, and it took the focus on opening — so
|
||||
the keyboard came up and covered half the list you had opened it to choose
|
||||
from. On a touchscreen the chosen model takes the focus instead; the filter is
|
||||
one tap away. With a mouse, typing straight into the filter works as before.
|
||||
|
||||
## 1.8.2
|
||||
|
||||
The model lists, made readable. Both printed every capability switch as a tag —
|
||||
reasoning, vision, tools and then seventeen `tool_*` names — for every model.
|
||||
|
||||
- **The model picker in a chat is name, context window and an eye.** One line per
|
||||
model: its name, its context window shortened the way it is quoted (`CTX 131K`,
|
||||
`CTX 1M`), and an eye if it can see images — nothing if it cannot. The tags and
|
||||
the description are gone from it; a menu whose one job is choosing does not
|
||||
need twenty badges per row. The context sizes and the eyes line up as columns
|
||||
whatever a name's length, and a model with no context length set shows nothing
|
||||
rather than `CTX 0`.
|
||||
|
||||
- **The tick follows the model you picked.** It stayed on the model the page was
|
||||
loaded with until the next reload, while the highlight moved.
|
||||
|
||||
- **Settings → Models no longer runs the tags over the names.** The tags sat
|
||||
beside the name, squeezed it to a word per line on a phone and drew over it at
|
||||
every width. The name now has the row to itself, with the same context size and
|
||||
eye as the picker, and the capability tags wrap underneath at the card's full
|
||||
width. A long name wraps rather than being cut off.
|
||||
|
||||
## 1.8.1
|
||||
|
||||
Three fixes to how a crowd behaves, found by reading one real round on the live
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "1.8.1"
|
||||
__version__ = "1.8.5"
|
||||
|
||||
@@ -679,6 +679,8 @@ MESSAGES.update(
|
||||
"Add a connection": "Pridať spojenie",
|
||||
"Models": "Modely",
|
||||
"Model": "Model",
|
||||
"Context window": "Kontextové okno",
|
||||
"Sees images": "Vidí obrázky",
|
||||
"Groups": "Skupiny",
|
||||
"Members": "Členovia",
|
||||
"Account": "Účet",
|
||||
|
||||
@@ -167,16 +167,24 @@ a.tabs__tab { text-decoration: none; }
|
||||
pinned to the scrollport with `background-attachment: local`, which is the old
|
||||
trick and works everywhere -- the `local` layers scroll with the content and
|
||||
cover the `scroll` ones exactly when there is nothing more to see.
|
||||
|
||||
⚠ "Cover" has to mean all of it. The covers used to be as wide as the
|
||||
shadows and solid for only 40% of that width, so the other 60% of every
|
||||
shadow always showed through, with nothing to scroll to. On Moria that is
|
||||
near-black on near-black and nobody saw it. On Shire it was a grey sliver at
|
||||
both ends of every tab bar. Each cover is now twice the shadow's width and
|
||||
solid across the first half, which is the whole shadow. It fades only past
|
||||
the shadow's end, so once content is scrolled the shadow shows as before.
|
||||
*/
|
||||
.tabs__bar {
|
||||
background-image:
|
||||
linear-gradient(to right, var(--bg) 40%, transparent),
|
||||
linear-gradient(to left, var(--bg) 40%, transparent),
|
||||
linear-gradient(to right, var(--bg) 50%, transparent),
|
||||
linear-gradient(to left, var(--bg) 50%, transparent),
|
||||
linear-gradient(to right, var(--scrim), transparent 1.5rem),
|
||||
linear-gradient(to left, var(--scrim), transparent 1.5rem);
|
||||
background-position: left center, right center, left center, right center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5rem 100%;
|
||||
background-size: 3rem 100%, 3rem 100%, 1.5rem 100%, 1.5rem 100%;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
/* A tab is a destination, so a flick should land on one rather than between
|
||||
two. */
|
||||
@@ -451,6 +459,35 @@ a.tabs__tab { text-decoration: none; }
|
||||
.model-list__item:first-child { padding-top: 0; }
|
||||
.model-list__item:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||
|
||||
/* The models card in /settings. Every row shares the list's column tracks, so
|
||||
the context sizes and the eyes line up; the description and the capability
|
||||
tags take the rest of the row underneath, never the space beside the name. */
|
||||
.model-list--models {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
column-gap: var(--sp-3);
|
||||
}
|
||||
.model-list--models .model-list__item {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
row-gap: var(--sp-1);
|
||||
}
|
||||
/* Wraps rather than truncates: there is room below, and a name cut to
|
||||
"Gemma 4 E…" on a phone is a different model's name. */
|
||||
.model-list__name {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-1) var(--sp-2);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.model-list__more { grid-column: 2 / -1; min-width: 0; }
|
||||
.model-list__tags { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
.model-list__tags:empty { display: none; }
|
||||
|
||||
/* --- Permission grids ------------------------------------------------------ */
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
|
||||
@@ -452,7 +452,17 @@ input.visually-hidden[type="checkbox"] {
|
||||
child will not shrink below its content without it, so a scroller missing it
|
||||
grows its parent instead of scrolling inside it. `.thread-scroll` relied on a
|
||||
scroll container's automatic minimum size to get away with omitting it, which
|
||||
is true and is not something the next person should have to know. */
|
||||
is true and is not something the next person should have to know.
|
||||
|
||||
`position: relative` makes each scroller the containing block for what is in
|
||||
it, and without it a `.visually-hidden` label is not in it at all. That class
|
||||
is `position: absolute`, so with no positioned ancestor it is placed against
|
||||
the *page*, at its static position -- six thousand pixels down the Tools
|
||||
panel on /admin/prompts -- and the document grew to 6771px behind a root
|
||||
that is `overflow: hidden`. Nobody can scroll that by hand, but
|
||||
`scrollIntoView()` and `focus()` scroll every ancestor that can scroll,
|
||||
and the root can. Switching a tab there lifted the whole shell 56px: the
|
||||
topbar gone off the top and a strip of bare background under everything. */
|
||||
.scroll-region,
|
||||
.sidebar__scroll,
|
||||
.inspector__body,
|
||||
@@ -460,6 +470,7 @@ input.visually-hidden[type="checkbox"] {
|
||||
.thread-scroll,
|
||||
.admin-scroll,
|
||||
.main > .tabs > .tabs__body {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -1377,6 +1388,23 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
which nothing else on the screen tells you -- gets the room back. */
|
||||
.topbar__actions .picker__label { display: none; }
|
||||
|
||||
/* And the menu is the bar's, not the picker's. Anchored to the picker it
|
||||
opens `right: 0` of a button that sits mid-bar with the panel buttons to
|
||||
its right, so a 24rem menu ran off the left edge of a 390px phone and cut
|
||||
every name in half. Taking `position` off the picker makes the bar the
|
||||
containing block: the menu spans the bar under it, whatever sits where --
|
||||
up to its usual 24rem, held at the bar's right edge by the auto margin. */
|
||||
.topbar { position: relative; }
|
||||
.topbar__actions .picker { position: static; }
|
||||
.topbar__actions .picker__menu {
|
||||
left: max(var(--sp-2), var(--safe-left));
|
||||
right: max(var(--sp-2), var(--safe-right));
|
||||
width: auto;
|
||||
max-width: 24rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.topbar__actions .picker__list { max-height: min(22rem, 60dvh); }
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
@@ -1730,6 +1758,32 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
color: var(--leaf);
|
||||
}
|
||||
.picker__tick { color: var(--accent); flex: none; margin-top: 0.35rem; }
|
||||
|
||||
/* The model picker: one row per model on the list's own column tracks, so the
|
||||
context sizes and the eyes form columns whatever a name's length. Scoped to
|
||||
the modifier because `.picker__list` is also the @-mention menu's list. */
|
||||
.picker__list--models {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto auto;
|
||||
column-gap: var(--sp-3);
|
||||
}
|
||||
.picker__list--models .picker__option {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
gap: inherit;
|
||||
}
|
||||
.picker__list--models .picker__option .picker__avatar { margin-top: 0; }
|
||||
.picker__list--models .picker__option-name { min-width: 0; }
|
||||
.model-ctx {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-vision { display: flex; color: var(--ink-faint); min-width: 1rem; }
|
||||
.picker__list--models .picker__tick { display: flex; margin-top: 0; min-width: 1rem; visibility: hidden; }
|
||||
.picker__list--models .picker__option.is-selected .picker__tick { visibility: visible; }
|
||||
.picker__empty {
|
||||
padding: var(--sp-4);
|
||||
margin: 0;
|
||||
|
||||
@@ -1099,27 +1099,59 @@
|
||||
The worker no longer takes over open pages on its own -- see sw.js -- so
|
||||
something has to say that one is waiting, and the reader decides. A toast
|
||||
rather than a reload: an application with a reply streaming into it must
|
||||
not be navigated out from under somebody. */
|
||||
not be navigated out from under somebody.
|
||||
|
||||
🚨 "A worker is waiting" is not the same as "this page is out of date",
|
||||
and the toast used to treat them as one. After a release it offered a
|
||||
reload on every page, including one just fetched with Ctrl+Shift+R, and
|
||||
reloading could not make it stop. A page is always fetched from the network
|
||||
and every asset it names carries `?v=<release>`, so a page loaded after
|
||||
the update IS the update, whichever worker happens to control it. And the
|
||||
worker that controls it is nearly always the previous one: a reload
|
||||
creates the new page before the old one goes away, so the old worker
|
||||
never runs out of pages and the new one never stops waiting.
|
||||
|
||||
So the question is asked of the page. The worker's release is in its own
|
||||
script URL (`/sw.js?v=`), and the page's is `window.lembasRelease` from
|
||||
base.html. When the two match there is nothing newer to reload into, and
|
||||
the worker is left to take over once the old tabs are closed. */
|
||||
var PAGE_RELEASE = window.lembasRelease || "";
|
||||
|
||||
function releaseOf(worker) {
|
||||
try {
|
||||
return new URL(worker.scriptURL).searchParams.get("v") || "";
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/* Unknown on either side counts as newer: better an extra offer than a
|
||||
release nobody is told about. */
|
||||
function isNewerThanThisPage(worker) {
|
||||
var release = releaseOf(worker);
|
||||
return !PAGE_RELEASE || !release || release !== PAGE_RELEASE;
|
||||
}
|
||||
|
||||
function offerReload(worker) {
|
||||
window.lembas.notify(
|
||||
"A new version is ready. Reload to use it.",
|
||||
{ kind: "info", action: { label: "Reload", run: function () {
|
||||
worker.postMessage({ type: "SKIP_WAITING" });
|
||||
} } }
|
||||
);
|
||||
}
|
||||
|
||||
function watchForUpdate(registration) {
|
||||
function offer(worker) {
|
||||
if (!worker || !navigator.serviceWorker.controller) return;
|
||||
worker.addEventListener("statechange", function () {
|
||||
if (worker.state !== "installed") return;
|
||||
window.lembas.notify(
|
||||
"A new version is ready. Reload to use it.",
|
||||
{ kind: "info", action: { label: "Reload", run: function () {
|
||||
worker.postMessage({ type: "SKIP_WAITING" });
|
||||
} } }
|
||||
);
|
||||
if (isNewerThanThisPage(worker)) offerReload(worker);
|
||||
});
|
||||
}
|
||||
if (registration.waiting && navigator.serviceWorker.controller) {
|
||||
window.lembas.notify(
|
||||
"A new version is ready. Reload to use it.",
|
||||
{ kind: "info", action: { label: "Reload", run: function () {
|
||||
registration.waiting.postMessage({ type: "SKIP_WAITING" });
|
||||
} } }
|
||||
);
|
||||
if (registration.waiting && navigator.serviceWorker.controller &&
|
||||
isNewerThanThisPage(registration.waiting)) {
|
||||
offerReload(registration.waiting);
|
||||
}
|
||||
registration.addEventListener("updatefound", function () {
|
||||
offer(registration.installing);
|
||||
@@ -1130,17 +1162,24 @@
|
||||
the right answer to it -- the page is now being served by a worker whose
|
||||
cache it did not start from.
|
||||
|
||||
Two guards, and the second is the one that is easy to miss. A flag, because
|
||||
`controllerchange` can fire more than once. And `hadController`, because on
|
||||
a *first* visit there is no worker at all: the one that installs then calls
|
||||
`clients.claim()`, which fires this event for the first time -- so without
|
||||
it, the very first page anybody loads reloads itself in front of them for
|
||||
no reason they could possibly work out. */
|
||||
Three guards, and the second is the one that is easy to miss. A flag,
|
||||
because `controllerchange` can fire more than once. And `hadController`,
|
||||
because on a *first* visit there is no worker at all: the one that
|
||||
installs then calls `clients.claim()`, which fires this event for the
|
||||
first time -- so without it, the very first page anybody loads reloads
|
||||
itself in front of them for no reason they could possibly work out.
|
||||
|
||||
The third is the same question as the toast's. Somebody pressing Reload in
|
||||
one tab activates the worker for all of them, and a tab that was already
|
||||
rendered by that release has nothing to gain from a reload -- and may have
|
||||
a reply streaming into it. */
|
||||
var reloading = false;
|
||||
if ("serviceWorker" in navigator) {
|
||||
var hadController = !!navigator.serviceWorker.controller;
|
||||
navigator.serviceWorker.addEventListener("controllerchange", function () {
|
||||
if (reloading || !hadController) return;
|
||||
var controller = navigator.serviceWorker.controller;
|
||||
if (controller && !isNewerThanThisPage(controller)) return;
|
||||
reloading = true;
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
@@ -320,6 +320,11 @@
|
||||
if (filter) {
|
||||
filter.value = "";
|
||||
applyFilter(menu, "");
|
||||
}
|
||||
// Not on a touchscreen: focusing a text field there raises the keyboard,
|
||||
// which covers half the list the finger came to choose from. The filter
|
||||
// is one tap away for whoever wants it.
|
||||
if (filter && !window.matchMedia("(hover: none)").matches) {
|
||||
filter.focus();
|
||||
} else {
|
||||
var selected = menu.querySelector(".picker__option.is-selected") ||
|
||||
@@ -1128,7 +1133,19 @@ document.addEventListener("lembas:notify", function (event) {
|
||||
shrinks the document and scrollTop is clamped to the new maximum, which
|
||||
for a short panel is somewhere below everything. */
|
||||
var outer = scroller(bar);
|
||||
if (outer && outer !== body) bar.scrollIntoView({ block: "start" });
|
||||
if (!outer || outer === body) return;
|
||||
/* Moved by hand, and only `outer`. `scrollIntoView` scrolls *every*
|
||||
ancestor that can scroll, the document included -- and the document
|
||||
could, by the height of whatever leaked out of the scroller, so a tab
|
||||
switch lifted the whole shell and left a strip of background under it.
|
||||
The containing block in app.css stops the leak; this stops a leak
|
||||
anyone adds later from being turned into a visible one.
|
||||
|
||||
Measured from `.tabs`, not the bar: the bar is sticky, so once the page
|
||||
is scrolled past the lede it reports the scroller's own top and the
|
||||
sum below would come out as nothing to do. */
|
||||
var tabs = bar.parentElement;
|
||||
outer.scrollTop += tabs.getBoundingClientRect().top - outer.getBoundingClientRect().top;
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -152,6 +152,9 @@
|
||||
what `app.js` turns into a sentence on the settings page.
|
||||
#}
|
||||
<script>
|
||||
/* The release this page was rendered by, for `app.js` to hold a waiting
|
||||
worker up against -- see "A release that arrived while you were reading". */
|
||||
window.lembasRelease = {{ version | tojson }};
|
||||
window.lembasWorker = { state: "unsupported" };
|
||||
if (!window.isSecureContext) {
|
||||
/* Reported separately from an outright failure: the fix is different. */
|
||||
|
||||
@@ -3,9 +3,16 @@
|
||||
Model picker.
|
||||
|
||||
A real dropdown rather than a <select>, because a <select> cannot show an
|
||||
image, a description or capability badges -- browsers render only text in an
|
||||
<option>. The hidden input is what actually carries the value, so the control
|
||||
still behaves like a form field.
|
||||
image or an icon -- browsers render only text in an <option>. The hidden
|
||||
input is what actually carries the value, so the control still behaves like
|
||||
a form field.
|
||||
|
||||
Each option is name, context window and an eye for vision, and nothing else.
|
||||
It listed every capability switch as a tag until 1.8.2, which is twenty
|
||||
`tool_*` tags per model in a menu whose one job is choosing; the full list is
|
||||
on /settings. The options share the list's column tracks (subgrid), so the
|
||||
context sizes and the eyes line up whatever a name's length -- and every
|
||||
option emits every slot, empty or not, or its row shifts.
|
||||
|
||||
Inside a chat it PATCHes the chat; on /chat it navigates, because there is no
|
||||
chat row to patch yet.
|
||||
@@ -31,7 +38,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="picker__list">
|
||||
<div class="picker__list picker__list--models">
|
||||
{% for model in models %}
|
||||
<button class="picker__option {{ 'is-selected' if current_model and model.model_id == current_model.model_id }}"
|
||||
type="button" role="option"
|
||||
@@ -39,23 +46,22 @@
|
||||
data-picker-value="{{ model.model_id }}"
|
||||
data-picker-search="{{ model.label|lower }} {{ model.model_id|lower }}">
|
||||
{{ model_avatar(model, cls="picker__avatar") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">
|
||||
{{ model.label }}
|
||||
{% if model.pinned %}{{ icon("pin", "icon--sm picker__pin") }}{% endif %}
|
||||
</span>
|
||||
{% if model.description %}
|
||||
<span class="picker__option-desc">{{ model.description }}</span>
|
||||
{% endif %}
|
||||
<span class="picker__option-tags">
|
||||
{% for name, on in (model.capabilities_json or {}).items() %}
|
||||
{% if on %}<span class="tag">{{ name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
</span>
|
||||
<span class="picker__option-name">
|
||||
<span class="truncate">{{ model.label }}</span>
|
||||
{% if model.pinned %}{{ icon("pin", "icon--sm picker__pin") }}{% endif %}
|
||||
</span>
|
||||
{% if current_model and model.model_id == current_model.model_id %}
|
||||
{{ icon("check", "icon--sm picker__tick") }}
|
||||
{% endif %}
|
||||
<span class="model-ctx mono"
|
||||
{% if model.context_length %}title="{{ t('Context window') }}: {{ model.context_length }}"{% endif %}>
|
||||
{%- if model.context_length %}CTX {{ model.context_length|context_size }}{% endif -%}
|
||||
</span>
|
||||
<span class="model-vision">
|
||||
{%- if (model.capabilities_json or {}).get("vision") -%}
|
||||
{{ icon("eye", "icon--sm") }}<span class="visually-hidden">{{ t("Sees images") }}</span>
|
||||
{%- endif -%}
|
||||
</span>
|
||||
{# Always rendered and shown by `.is-selected`, so it follows ui.js's
|
||||
in-place choice rather than staying on the model the page loaded with. #}
|
||||
<span class="picker__tick">{{ icon("check", "icon--sm") }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -135,6 +135,10 @@
|
||||
<circle cx="9" cy="10" r="1.6"/>
|
||||
<path d="m4.5 17 4.2-4.2a1.5 1.5 0 0 1 2.1 0l3 3 1.9-1.9a1.5 1.5 0 0 1 2.1 0l2 2"/>
|
||||
</symbol>
|
||||
<symbol id="i-eye" viewBox="0 0 24 24">
|
||||
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</symbol>
|
||||
<symbol id="i-arrow-up" viewBox="0 0 24 24"><path d="M12 19V6M6 12l6-6 6 6"/></symbol>
|
||||
<symbol id="i-arrow-down" viewBox="0 0 24 24"><path d="M12 5v13M6 12l6 6 6-6"/></symbol>
|
||||
<symbol id="i-star" viewBox="0 0 24 24">
|
||||
|
||||
@@ -145,23 +145,35 @@
|
||||
<div class="card">
|
||||
<h2 class="card__title">{{ t("Available to you") }}</h2>
|
||||
<p class="card__lede">{{ t("In the order an administrator arranged them.") }}</p>
|
||||
<ul class="model-list">
|
||||
{# Name, context window and vision on one line, on the list's
|
||||
column tracks so they line up down the card; the capability
|
||||
switches wrap underneath at the full width. They sat beside
|
||||
the name until 1.8.2 and, twenty tags long, squeezed it to a
|
||||
word per line and ran over it. #}
|
||||
<ul class="model-list model-list--models">
|
||||
{% for model in models %}
|
||||
<li class="model-list__item">
|
||||
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
||||
{{ model_avatar(model, cls="nav-item__avatar") }}
|
||||
<div style="min-width: 0">
|
||||
<strong>{{ model.label }}</strong>
|
||||
{% if model.description %}
|
||||
<div class="text-xs faint">{{ model.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
{% for name, on in (model.capabilities_json or {}).items() %}
|
||||
{% if on %}<span class="badge badge--leaf">{{ name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
{{ model_avatar(model, cls="nav-item__avatar") }}
|
||||
<strong class="model-list__name">
|
||||
<span>{{ model.label }}</span>
|
||||
{% if model.pinned %}<span class="badge">{{ t("pinned") }}</span>{% endif %}
|
||||
</strong>
|
||||
<span class="model-ctx mono"
|
||||
{% if model.context_length %}title="{{ t('Context window') }}: {{ model.context_length }}"{% endif %}>
|
||||
{%- if model.context_length %}CTX {{ model.context_length|context_size }}{% endif -%}
|
||||
</span>
|
||||
<span class="model-vision">
|
||||
{%- if (model.capabilities_json or {}).get("vision") -%}
|
||||
{{ icon("eye", "icon--sm") }}<span class="visually-hidden">{{ t("Sees images") }}</span>
|
||||
{%- endif -%}
|
||||
</span>
|
||||
{% if model.description %}
|
||||
<div class="model-list__more text-xs faint">{{ model.description }}</div>
|
||||
{% endif %}
|
||||
<div class="model-list__more model-list__tags">
|
||||
{%- for name, on in (model.capabilities_json or {}).items() -%}
|
||||
{%- if on %}<span class="tag">{{ name }}</span>{% endif -%}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -63,6 +63,26 @@ def stable_hue(value: str) -> int:
|
||||
|
||||
templates.env.filters["stable_hue"] = stable_hue
|
||||
|
||||
|
||||
def context_size(tokens: int | None) -> str:
|
||||
"""A context window as a model list shows it: 131072 -> "131K".
|
||||
|
||||
Decimal thousands, because that is how the number is quoted everywhere a
|
||||
person reads it, and a picker that said "128K" for a 131072-token model
|
||||
would disagree with the admin page's own figure. Empty for an unknown size,
|
||||
so the column slot is still emitted and the next row does not shift.
|
||||
"""
|
||||
if not tokens or tokens <= 0:
|
||||
return ""
|
||||
if tokens < 1000:
|
||||
return str(tokens)
|
||||
if tokens < 1_000_000:
|
||||
return f"{round(tokens / 1000)}K"
|
||||
return f"{tokens / 1_000_000:.1f}".removesuffix(".0") + "M"
|
||||
|
||||
|
||||
templates.env.filters["context_size"] = context_size
|
||||
|
||||
# A user's own message: escaped here and marked up, so `@mentions` read as
|
||||
# references rather than as punctuation. A filter rather than a context value
|
||||
# because the message templates are included from four different handlers and
|
||||
|
||||
@@ -208,3 +208,19 @@ def test_the_desktop_minimum_is_still_declared():
|
||||
for token in ("--terminal-width-min", "--canvas-width-min"):
|
||||
assert f"{token}:" in TOKENS
|
||||
assert f"var({token})" in APP_CSS
|
||||
|
||||
|
||||
def test_the_topbar_model_menu_belongs_to_the_bar_on_a_phone():
|
||||
"""1.8.3. Anchored to the picker, the menu opened `right: 0` of a button that
|
||||
sits mid-bar with the panel buttons to its right, so on a 390px phone a 24rem
|
||||
menu started 132px left of the screen and every model's name was cut off.
|
||||
|
||||
Below the phone breakpoint the picker gives up `position`, which makes the bar
|
||||
the containing block, and the menu is pinned between the bar's two edges."""
|
||||
body = _media_body(APP_CSS, "48rem")
|
||||
assert re.search(r"\.topbar\s*\{\s*position:\s*relative", body)
|
||||
assert re.search(r"\.topbar__actions \.picker\s*\{\s*position:\s*static", body)
|
||||
menu = re.search(r"\.topbar__actions \.picker__menu\s*\{([^}]*)\}", body)
|
||||
assert menu, "the topbar's menu is not placed on a phone"
|
||||
for declaration in ("left:", "right:", "width: auto"):
|
||||
assert declaration in menu.group(1), f"{declaration} missing from the phone menu"
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""The two places a person reads the list of models: the chat's picker and /settings.
|
||||
|
||||
Until 1.8.2 both printed every capability switch as a tag -- twenty `tool_*`
|
||||
entries per model -- and in /settings the tags sat beside the name and squeezed
|
||||
it to a word per line underneath them. The picker is now name, context window
|
||||
and an eye for vision; /settings keeps the tags, underneath.
|
||||
|
||||
The layout is by construction (the rows share the list's column tracks), and
|
||||
that only holds while every row emits every slot, so a model with no context
|
||||
length and no vision is asserted to still have both cells.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from lembas.db.models import Connection, Model
|
||||
from lembas.services.crypto import encrypt
|
||||
from lembas.web.templating import context_size
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tokens", "shown"),
|
||||
[
|
||||
(None, ""),
|
||||
(0, ""),
|
||||
(512, "512"),
|
||||
(4096, "4K"),
|
||||
(32768, "33K"),
|
||||
(131072, "131K"),
|
||||
(262144, "262K"),
|
||||
(1_000_000, "1M"),
|
||||
(1_048_576, "1M"),
|
||||
(2_000_000, "2M"),
|
||||
(1_500_000, "1.5M"),
|
||||
],
|
||||
)
|
||||
def test_a_context_window_is_shortened_the_way_it_is_quoted(tokens, shown):
|
||||
assert context_size(tokens) == shown
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def models(db, registered):
|
||||
connection = Connection(
|
||||
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
|
||||
)
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
db.add_all(
|
||||
[
|
||||
Model(
|
||||
connection_id=connection.id,
|
||||
model_id="sees",
|
||||
display_name="Sees",
|
||||
position=0,
|
||||
context_length=131072,
|
||||
capabilities_json={"vision": True, "tools": True, "tool_fetch": True},
|
||||
),
|
||||
Model(
|
||||
connection_id=connection.id,
|
||||
model_id="blind",
|
||||
display_name="Blind",
|
||||
position=1,
|
||||
capabilities_json={"tools": True, "tool_fetch": True},
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _options(html: str) -> dict[str, str]:
|
||||
"""The model picker's options by model id -- not the @-mention menu's."""
|
||||
found = {}
|
||||
for body in re.findall(r'<button class="picker__option\b.*?</button>', html, re.S):
|
||||
value = re.search(r'data-picker-value="([^"]+)"', body)
|
||||
if value:
|
||||
found[value.group(1)] = body
|
||||
return found
|
||||
|
||||
|
||||
def test_the_picker_shows_name_context_and_vision_and_no_tags(client, models):
|
||||
html = client.get("/chat?model=sees").text
|
||||
options = _options(html)
|
||||
assert set(options) == {"sees", "blind"}
|
||||
|
||||
sees = options["sees"]
|
||||
assert "Sees" in sees
|
||||
assert "CTX 131K" in sees
|
||||
assert "#i-eye" in sees
|
||||
assert 'class="tag"' not in sees
|
||||
assert "tool_fetch" not in sees
|
||||
|
||||
|
||||
def test_every_picker_row_emits_every_slot(client, models):
|
||||
blind = _options(client.get("/chat?model=sees").text)["blind"]
|
||||
assert "#i-eye" not in blind
|
||||
assert "CTX" not in blind
|
||||
slots = ("picker__avatar", "picker__option-name", "model-ctx", "model-vision", "picker__tick")
|
||||
for slot in slots:
|
||||
assert slot in blind, slot
|
||||
|
||||
|
||||
def test_settings_lists_the_models_with_their_tags_below_the_name(client, models):
|
||||
html = client.get("/settings").text
|
||||
listing = html[html.index('class="model-list model-list--models"'):]
|
||||
listing = listing[: listing.index("</ul>")]
|
||||
assert listing.count('class="model-list__item"') == 2
|
||||
assert "CTX 131K" in listing
|
||||
assert listing.count("#i-eye") == 1
|
||||
assert listing.count('class="model-list__more model-list__tags"') == 2
|
||||
assert "tool_fetch" in listing
|
||||
|
||||
|
||||
def test_opening_the_picker_on_a_touchscreen_does_not_raise_the_keyboard():
|
||||
"""1.8.3. With more than eight models the menu has a filter, and `open()`
|
||||
focused it -- which on a phone raises the keyboard over half the list the
|
||||
finger came to choose from. The focus is gated on `(hover: none)`, the same
|
||||
query the stylesheet uses for touch."""
|
||||
from pathlib import Path
|
||||
|
||||
import lembas
|
||||
|
||||
js = (Path(lembas.__file__).parent / "web/static/js/ui.js").read_text(encoding="utf-8")
|
||||
start = js.index("function open(picker)")
|
||||
body = js[start : js.index("function applyFilter", start)]
|
||||
assert "filter.focus()" in body, "the filter is no longer focused anywhere -- test is blind"
|
||||
gated = r'if \(filter && !window\.matchMedia\("\(hover: none\)"\)\.matches\)\s*\{'
|
||||
assert re.search(gated + r"\s*filter\.focus\(\)", body), (
|
||||
"the filter is focused on open without asking whether this is a touchscreen"
|
||||
)
|
||||
@@ -311,3 +311,33 @@ def test_the_worker_precaches_what_a_page_will_ask_for():
|
||||
source = (STATIC_DIR / "js" / "sw.js").read_text()
|
||||
assert 'path + "?v=" + VERSION' in source
|
||||
assert "versioned(path)" in source
|
||||
|
||||
|
||||
def test_the_update_offer_asks_whether_this_page_is_older(client: TestClient, registered):
|
||||
"""After a release the toast offered a reload on every page, including one
|
||||
just fetched with Ctrl+Shift+R, and reloading never made it go away. It
|
||||
fired whenever a worker was waiting. But a page loaded after the update
|
||||
already IS the update: it comes from the network, and every asset it names
|
||||
carries `?v=`. The worker that waits is nearly always the previous one,
|
||||
still holding the tab, because a reload never lets it run out of pages.
|
||||
|
||||
So the offer, and the automatic reload when another tab accepts it, compare
|
||||
the worker's release with the page's own. Driven under Node against stubs
|
||||
before committing: 1.8.3 offered a reload to a current page and to a page
|
||||
that another tab's Reload had just made current. This does neither, and
|
||||
still offers it to a page from an older release.
|
||||
"""
|
||||
import re
|
||||
|
||||
import lembas
|
||||
|
||||
page = client.get("/chat").text
|
||||
assert f"window.lembasRelease = \"{lembas.__version__}\";" in page
|
||||
|
||||
source = (STATIC_DIR / "js" / "app.js").read_text()
|
||||
code = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
|
||||
start = code.index("function watchForUpdate")
|
||||
block = code[start : code.index("navigator.serviceWorker.ready.then(watchForUpdate)")]
|
||||
# Both ways a waiting worker is found, and the controllerchange reload.
|
||||
assert block.count("isNewerThanThisPage(") == 3
|
||||
assert 'searchParams.get("v")' in code
|
||||
|
||||
@@ -177,6 +177,48 @@ def test_the_tab_reset_finds_the_container_that_actually_scrolls():
|
||||
assert "scrollHeight > " in SOURCE
|
||||
|
||||
|
||||
def test_a_tab_switch_moves_only_the_container_that_scrolls():
|
||||
"""Switching a tab on /admin/prompts lifted the whole shell 56px, with the
|
||||
topbar gone off the top and a strip of bare background under everything.
|
||||
|
||||
Two halves, and either one alone is enough to bring it back. The prompt
|
||||
cards' `.visually-hidden` labels are `position: absolute`. With no
|
||||
positioned ancestor they were placed against the page and stretched the
|
||||
document to 6771px behind an `overflow: hidden` root. And the handler used
|
||||
`scrollIntoView`, which scrolls every ancestor that can scroll, the root
|
||||
included. Measured in headless Chromium at 1640x930 before and after.
|
||||
"""
|
||||
app = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
|
||||
start = app.index(".scroll-region,")
|
||||
rule = app[start : app.index("}", start)]
|
||||
assert ".admin-scroll" in rule
|
||||
assert "position: relative" in rule
|
||||
|
||||
code = re.sub(r"/\*.*?\*/", "", SOURCE, flags=re.S)
|
||||
start = code.index('closest(".tabs__bar")')
|
||||
handler = code[start : code.index("})();", start)]
|
||||
assert "scrollIntoView" not in handler
|
||||
assert "outer.scrollTop" in handler
|
||||
|
||||
|
||||
def test_the_tab_bar_edge_fade_is_covered_when_nothing_overflows():
|
||||
"""The covers were as wide as the shadows and solid for only 40% of that,
|
||||
so 60% of each shadow showed through with nothing to scroll to. On Shire
|
||||
that was a grey sliver at both ends of every tab bar. A cover has to be
|
||||
solid across the whole shadow. Measured in headless Chromium on
|
||||
/admin/prompts and /settings in both themes before and after.
|
||||
"""
|
||||
admin = (ROOT / "web/static/css/admin.css").read_text(encoding="utf-8")
|
||||
start = admin.index("background-attachment: local, local, scroll, scroll")
|
||||
block = admin[admin.rindex(".tabs__bar {", 0, start) : start]
|
||||
cover = re.search(r"linear-gradient\(to right, var\(--bg\) (\d+)%, transparent\)", block)
|
||||
shadow = re.search(r"var\(--scrim\), transparent ([\d.]+)rem", block)
|
||||
sizes = re.search(r"background-size: ([\d.]+)rem 100%, [\d.]+rem 100%, ([\d.]+)rem 100%", block)
|
||||
assert cover and shadow and sizes, "the edge-fade rule changed shape; re-check it by eye"
|
||||
solid = float(sizes.group(1)) * int(cover.group(1)) / 100
|
||||
assert solid >= float(shadow.group(1)) == float(sizes.group(2))
|
||||
|
||||
|
||||
def test_the_two_ends_of_the_shell_stay_level():
|
||||
"""The sidebar footer and the composer sit either side of the same vertical
|
||||
edge and are both content-sized, so without a common floor they end at
|
||||
|
||||
Reference in New Issue
Block a user