Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da43bc1459
|
@@ -16,6 +16,28 @@ for 1.0.0 have something to be assembled from.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 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
|
## 1.8.1
|
||||||
|
|
||||||
Three fixes to how a crowd behaves, found by reading one real round on the live
|
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."""
|
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||||
|
|
||||||
__version__ = "1.8.1"
|
__version__ = "1.8.2"
|
||||||
|
|||||||
@@ -679,6 +679,8 @@ MESSAGES.update(
|
|||||||
"Add a connection": "Pridať spojenie",
|
"Add a connection": "Pridať spojenie",
|
||||||
"Models": "Modely",
|
"Models": "Modely",
|
||||||
"Model": "Model",
|
"Model": "Model",
|
||||||
|
"Context window": "Kontextové okno",
|
||||||
|
"Sees images": "Vidí obrázky",
|
||||||
"Groups": "Skupiny",
|
"Groups": "Skupiny",
|
||||||
"Members": "Členovia",
|
"Members": "Členovia",
|
||||||
"Account": "Účet",
|
"Account": "Účet",
|
||||||
|
|||||||
@@ -451,6 +451,35 @@ a.tabs__tab { text-decoration: none; }
|
|||||||
.model-list__item:first-child { padding-top: 0; }
|
.model-list__item:first-child { padding-top: 0; }
|
||||||
.model-list__item:last-child { border-bottom: 0; padding-bottom: 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 ------------------------------------------------------ */
|
/* --- Permission grids ------------------------------------------------------ */
|
||||||
.checkbox-row {
|
.checkbox-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1730,6 +1730,32 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
|||||||
color: var(--leaf);
|
color: var(--leaf);
|
||||||
}
|
}
|
||||||
.picker__tick { color: var(--accent); flex: none; margin-top: 0.35rem; }
|
.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 {
|
.picker__empty {
|
||||||
padding: var(--sp-4);
|
padding: var(--sp-4);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -3,9 +3,16 @@
|
|||||||
Model picker.
|
Model picker.
|
||||||
|
|
||||||
A real dropdown rather than a <select>, because a <select> cannot show an
|
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
|
image or an icon -- browsers render only text in an <option>. The hidden
|
||||||
<option>. The hidden input is what actually carries the value, so the control
|
input is what actually carries the value, so the control still behaves like
|
||||||
still behaves like a form field.
|
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
|
Inside a chat it PATCHes the chat; on /chat it navigates, because there is no
|
||||||
chat row to patch yet.
|
chat row to patch yet.
|
||||||
@@ -31,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="picker__list">
|
<div class="picker__list picker__list--models">
|
||||||
{% for model in models %}
|
{% for model in models %}
|
||||||
<button class="picker__option {{ 'is-selected' if current_model and model.model_id == current_model.model_id }}"
|
<button class="picker__option {{ 'is-selected' if current_model and model.model_id == current_model.model_id }}"
|
||||||
type="button" role="option"
|
type="button" role="option"
|
||||||
@@ -39,23 +46,22 @@
|
|||||||
data-picker-value="{{ model.model_id }}"
|
data-picker-value="{{ model.model_id }}"
|
||||||
data-picker-search="{{ model.label|lower }} {{ model.model_id|lower }}">
|
data-picker-search="{{ model.label|lower }} {{ model.model_id|lower }}">
|
||||||
{{ model_avatar(model, cls="picker__avatar") }}
|
{{ model_avatar(model, cls="picker__avatar") }}
|
||||||
<span class="picker__option-body">
|
<span class="picker__option-name">
|
||||||
<span class="picker__option-name">
|
<span class="truncate">{{ model.label }}</span>
|
||||||
{{ model.label }}
|
{% if model.pinned %}{{ icon("pin", "icon--sm picker__pin") }}{% endif %}
|
||||||
{% 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>
|
</span>
|
||||||
{% if current_model and model.model_id == current_model.model_id %}
|
<span class="model-ctx mono"
|
||||||
{{ icon("check", "icon--sm picker__tick") }}
|
{% if model.context_length %}title="{{ t('Context window') }}: {{ model.context_length }}"{% endif %}>
|
||||||
{% 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>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -135,6 +135,10 @@
|
|||||||
<circle cx="9" cy="10" r="1.6"/>
|
<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"/>
|
<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>
|
||||||
|
<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-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-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">
|
<symbol id="i-star" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -145,23 +145,35 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="card__title">{{ t("Available to you") }}</h2>
|
<h2 class="card__title">{{ t("Available to you") }}</h2>
|
||||||
<p class="card__lede">{{ t("In the order an administrator arranged them.") }}</p>
|
<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 %}
|
{% for model in models %}
|
||||||
<li class="model-list__item">
|
<li class="model-list__item">
|
||||||
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
{{ model_avatar(model, cls="nav-item__avatar") }}
|
||||||
{{ model_avatar(model, cls="nav-item__avatar") }}
|
<strong class="model-list__name">
|
||||||
<div style="min-width: 0">
|
<span>{{ model.label }}</span>
|
||||||
<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 %}
|
|
||||||
{% if model.pinned %}<span class="badge">{{ t("pinned") }}</span>{% endif %}
|
{% 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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -63,6 +63,26 @@ def stable_hue(value: str) -> int:
|
|||||||
|
|
||||||
templates.env.filters["stable_hue"] = stable_hue
|
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
|
# 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
|
# references rather than as punctuation. A filter rather than a context value
|
||||||
# because the message templates are included from four different handlers and
|
# because the message templates are included from four different handlers and
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""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
|
||||||
Reference in New Issue
Block a user