7 Commits
Author SHA1 Message Date
HomerandClaude Opus 5.5 cb8a223fa4 A dot on the loaded model, and a new chat that matches its model
The model menu asks each connection's /v1/models for the load state
llama-swap reports there and marks the loaded model; endpoints that state
nothing (a hosted API) get no dot. The new-chat screen offered the generic
three efforts whatever the model took, so Bonsai's xhigh default showed as
off. The composer was as wide as its widest hint. And the Doors of Durin are
a riddle: Speak friend and enter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:48:58 +00:00
HomerandClaude Opus 5.5 dfd8418d95 A tab bar's edge fade that stays hidden until something is off the edge
The local-attached covers were as wide as the scroll shadows and solid for
only 40% of that, so most of each shadow showed at rest -- invisible on Moria,
a grey sliver at both ends of every tab bar on Shire. The covers are now twice
the shadow's width and solid across all of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:53:45 +00:00
HomerandClaude Opus 5.5 db22962164 A reload offer only for a page that is older, and a tab switch that moves one scroller
The update toast fired whenever a service worker was waiting, so after every
release it appeared on pages that were already the release -- including one
fetched with Ctrl+Shift+R. It now compares the waiting worker's release with
the page's own, and so does the reload that follows another tab accepting it.

On Admin -> Prompts, visually hidden labels were positioned against the page
and made the document 6771px tall behind an overflow-hidden root; the tab
handler's scrollIntoView then scrolled that root and lifted the shell 56px.
Every scroll region is now a containing block, and the handler moves only the
container that scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:17:18 +00:00
HomerandClaude Opus 5.5 793f9c8cad A model menu that stays on the phone
Inside a chat the picker sits mid-bar with the panel buttons to its right,
and its menu opened from the picker's right edge -- at 390px it spanned
x = -132..226, cutting every model's name off. Below 48rem the bar is now
the containing block and the menu is pinned between its edges (capped at
24rem). Opening it on a touchscreen no longer focuses the filter, which
raised the keyboard over half the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:36:45 +00:00
HomerandClaude Opus 5.5 da43bc1459 Model lists you can read
The chat's model picker shows name, context window (CTX 131K) and an eye
for vision, on shared column tracks; the capability tags are gone from it.
Settings -> Models gives the name its own row and wraps the tags beneath.
The picker's tick now follows an in-place choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:54:31 +00:00
HomerandClaude Opus 5 8503c6c775 A crowd that does not agree with whoever spoke last
Three fixes to how a round behaves, found by reading one real round on the live
instance rather than by testing it.

A member asked "what would you have done differently" answered the person's
original question again instead of critiquing what was already there. Fine on a
question with one answer; on a request to *make* something it is an invitation.
`crowd.turn` now says to respond to what is above and not to re-answer.

The model that opened the round, told to write the final answer and take what
the others got right, abandoned its own good answer and adopted the newcomer's
position with no argument anywhere for why. Both closing fragments now say that
an answer is not the worse one for having been written first, and that agreement
with no argument behind it is not a reason to change.

That second one is not cosmetic: all three answers from the observed round were
compiled. The original and the critic's alternative both build; the merged
answer that was actually delivered does not. A crowd's failure mode is not
looping -- the caps handle that -- it is converging on the last thing said.

Third, the reply that opens a round now carries a chip like every other one. It
is the single contribution the crowd does not start, so there was nothing to
stamp it with until the round began, and a two-model round rendered as an
unmarked reply followed by one saying "2 of 2". The stamp is display state and
never scheduling state: `crowd.scheduling_state` hides it from everything that
decides what happens next, because fed to the scheduler it would inherit the
round's clock -- regenerating the opening an hour later would end the round with
"out of time" before anybody spoke -- and would hand that reply a member's tools
and a member's instruction.

And the chip was never translated. It is now, with the count as placeholders
rather than three t() calls around one sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 21:10:25 +00:00
HomerandClaude Opus 5 ab32c68a8f A crowd you can find, and a phone 65px too narrow
Two reports against 1.6.0 and 1.7.0, both correct.

The crowd worked end to end and was, in practice, not there: the picker was
behind the ⋯ menu of a chat that already existed, and the switch was a card on
the Agents page, which made it read as an agent-chat feature. The picker is now
a button in the composer toolbar on both screens that include it, and on the
new-chat screen the choice rides along with the first message, so a chat can
start as a crowd instead of having to be converted into one. The instance
switch has its own page.

The width bug was the suggestion cards, exactly as reported. `.suggestions`
rendered 455px inside a 366px column, and the tree's standing rule applied on
its own made it worse -- 428px to 455px. A grid item carries `min-width: auto`,
which is a min-content floor, and a floor beats `width: 100%`; the floor is
measured while the percentage is indefinite, so `min(100%, …)` alone sends the
track to a card's max-content. Both halves now go on all four auto-fit grids,
and a test refuses either alone.

It survived four releases of narrow-width checking because the harness never
rendered that screen: `TestClient(app)` runs no lifespan outside a `with` block,
so the startup-seeded cards were missing from every shot ever taken of it. And
its overflow check skipped anything inside a scroller -- right for a table in
its own scroller, blind to the scroller itself, which `overflow-y: auto` makes
scroll sideways too. Both fixed; it now names the box and the child to blame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 20:08:10 +00:00
43 changed files with 2015 additions and 246 deletions
+190
View File
@@ -16,6 +16,196 @@ for 1.0.0 have something to be assembled from.
## Unreleased ## Unreleased
## 1.9.0
The model menu says which model is loaded, and a new chat now matches the
model it is about to talk to.
- **A dot on the model that is loaded.** Opening the model menu asks each
connection which of its models is in memory. llama-swap says so in its
ordinary model list, so the one it is holding gets a green dot, and one being
loaded gets a pulsing amber one. Picking a model without a dot means waiting
for it to load first. A hosted API such as DeepSeek never unloads anything and
does not report it, so its models show no dot, not a false "not loaded". Each
connection is asked once per menu opening, at most every five seconds. One
that reports nothing is asked again only after ten minutes, and one that is
slow or down just leaves the menu without dots.
- **A new chat offers the model's own effort levels.** The new-chat screen
offered low, medium and high whatever the model took. On a model like Bonsai,
which takes low, medium and xhigh with xhigh as its default, the menu offered a
`high` it rejects. It had no xhigh, so it showed "off" while the chat it
created used xhigh. It now shows the same levels, and the same default, as the
chat will have.
- **The message box is the same width for every model.** It was sized by its
widest content, so the "has no vision, so images will not be sent" line made
it wider for models without vision than for models with it. It is now always
the width of the conversation column.
- **"Speak friend and enter."** The line under an empty chat (and on the "not
yours" error page) lost its commas. On the Doors of Durin it is a riddle: the
answer is to say *friend*, not to be greeted as one. Only the shipped wording
changed. An instance that has overridden the line keeps its own.
## 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
instance rather than by testing: two models, one round, a question that asked for
something to be *made*.
- **A member no longer answers the question again.** Asked to pick a language and
write an example, the main model wrote Python; the second model gave a genuinely
useful critique of it — and then answered the original question itself, in a
different language. Nothing in its instruction said not to. It now says so:
*respond to what is above you; do not answer the person's original request again
yourself.* A member that produces a rival answer is not a second opinion, it is
a second first opinion, and it is what takes a round off the question.
- **The model that opened the round no longer capitulates.** Told to write the
final answer and take what the others got right, it abandoned its own perfectly
good answer, wrote *"I agree that Rust is the superior choice"* with no argument
anywhere for why, and rewrote everything in the newcomer's language. Both
closing instructions now carry: *your own answer is not automatically the worse
one for having been written first; change your position where somebody gave you
a reason, and say what the reason was.*
This mattered more than it reads. All three answers were compiled: the original
Python was fine, the critic's Rust compiled and ran — and **the merged answer
that was actually delivered did not compile at all**. A crowd that ends by
agreeing with whoever spoke last can be worse than the model that started it.
- **The bubble that opens a round now says `1 of 3` like every other one.** It was
the single contribution with no chip, because the crowd does not start it — the
composer does, and a round only begins when it finishes. So a two-model round
read as an ordinary reply followed by one labelled `2 of 2`, with no 1 anywhere.
It is stamped when the round begins, and that stamp is deliberately invisible to
everything that decides what happens next: fed to the scheduler it would inherit
the round's clock, so regenerating the opening an hour later would end the round
with "out of time" before anybody spoke.
- Fixed: **the crowd chip was never translated.** `1 of 3`, `on the way back`,
`closing`, `no rounds left` and the rest were English on a Slovak instance.
**Worth knowing, and not a bug:** with **two** models there is no backward pass at
all. The way back would contain only the model that opened the round, whose turn
*is* the close — so `crowd.disagree` never fires. You need at least three models
before a single "do you disagree" bubble can exist.
## 1.8.0
- **The crowd is where you would look for it.** In 1.6.0 the only way to add a
model to a chat was the Chat settings panel — behind the ⋯ menu, inside a chat
that already existed — and the switch that turns the feature on was a card on
the Agents page. Somebody who enabled it went looking and found nothing, which
is the correct outcome of that arrangement.
Now there is a **crowd button in the composer**, beside the attachment and
scope buttons, on both the chat screen and Messages. It carries a count when
the chat has a crowd, it lists the models you can reach, and it says what the
turn will cost before you tick anything. On the new-chat screen the choice
**rides along with the first message**, so a chat can start as a crowd rather
than having to be converted into one.
The instance switch and its bounds have moved to their own page, **Admin →
Crowd**.
- Fixed: **the new-chat screen was wider than a phone.** Before the first
message, the suggestion cards pushed the conversation 65px past the edge of a
390px screen and it could be dragged sideways; after the first message it
looked right, because the cards were gone. Reported from a phone.
Two things were true at once. The cards' grid asked for a minimum column width
it could not give up — the ordinary version of this bug — and it was *also* a
grid item, which means it carried a min-content floor that beats `width: 100%`
outright. Fixing only the first made it 27px worse. Both are fixed, on all four
grids in the stylesheets that could have it, and a test now refuses either half
of the pair on its own.
The reason this survived four releases of narrow-width checking is worth
recording: the screenshot harness built its client without running the
application's startup, so the suggestion cards were **absent from every shot
ever taken of that screen**, and its overflow check deliberately ignored
anything inside a scrolling box — correct for a wide table in its own scroller,
blind to a box that scrolls sideways when nobody asked it to. Both are fixed,
and the harness now names the offending element and the child responsible.
## 1.7.0 ## 1.7.0
- **The interface speaks Slovak.** Pick a language under **Appearance** in your - **The interface speaks Slovak.** Pick a language under **Appearance** in your
+128 -1
View File
@@ -26,6 +26,7 @@ import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from functools import cache
from pathlib import Path from pathlib import Path
REPO = Path(__file__).resolve().parent.parent REPO = Path(__file__).resolve().parent.parent
@@ -128,8 +129,65 @@ window.__measure = function () {
return found.sort(function (a, b) { return b.over - a.over; }).slice(0, 8); return found.sort(function (a, b) { return b.over - a.over; }).slice(0, 8);
} }
/* --- A box that scrolls sideways when nobody asked it to -----------------
The blind spot that hid the suggestions bug through forty measurements.
`.suggestions` rendered 455px wide inside a 390px `.thread-scroll`, and
every check above looked straight past it: `culprits('x')` skips anything
with a scrollable ancestor -- correct for a table inside its own scroller,
wrong for the scroller itself -- and `scrollsSideways` stayed false because
`.thread-scroll` absorbed the overflow instead of the document.
"Authored" is the distinction that makes this reportable rather than noise.
The tree's rule is that anything wide gets its OWN scroller, so a wrapper
carrying `overflow-x: auto` in a stylesheet is right. A box given only
`overflow-y: auto` scrolls sideways as well, because the other axis then
computes to `auto` -- and that is always a bug. Computed style cannot tell
those apart, both being `auto`, so the rules that say it are read off the
stylesheets -- in Python, by `authored_sideways()` below, and not from the
CSSOM here: a stylesheet loaded over `file://` is a foreign origin for
`cssRules` even with `--allow-file-access-from-files`, and every sheet
throws. That silently found *nothing authored*, which turns this check into
"every vertical scroller is a bug" -- so the list arriving empty is a hard
error rather than a clean run. */
var sidewaysAuthors = __SIDEWAYS_AUTHORS__;
function authoredSideways(el) {
if (el.style.overflowX || el.style.overflow) return true;
for (var i = 0; i < sidewaysAuthors.length; i++) {
try { if (el.matches(sidewaysAuthors[i])) return true; } catch (e) { /* :has() etc */ }
}
return false;
}
var sideways = [];
document.querySelectorAll('body, body *').forEach(function (el) {
var ox = getComputedStyle(el).overflowX;
if (ox !== 'auto' && ox !== 'scroll') return;
if (el.scrollWidth <= el.clientWidth + 1) return;
if (authoredSideways(el)) return;
/* Which child is doing it. "`.thread-scroll` scrolls sideways" is not
actionable; "`.suggestions` is 455px inside its 390px" is. */
var worst = null;
el.querySelectorAll('*').forEach(function (kid) {
var over = kid.getBoundingClientRect().width - el.clientWidth;
if (over > 1 && (!worst || over > worst.over)) {
worst = {tag: kid.tagName.toLowerCase(),
cls: (kid.className && kid.className.toString().slice(0, 50)) || '',
w: Math.round(kid.getBoundingClientRect().width),
over: Math.round(over)};
}
});
sideways.push({tag: el.tagName.toLowerCase(),
cls: (el.className && el.className.toString().slice(0, 50)) || '',
scrollW: el.scrollWidth, clientW: el.clientWidth,
widest: worst});
});
var shell = document.querySelector('.shell'); var shell = document.querySelector('.shell');
return { return {
sidewaysScrollers: sideways.slice(0, 8),
sidewaysCount: sideways.length,
docScrollH: de.scrollHeight, docScrollH: de.scrollHeight,
innerH: window.innerHeight, innerH: window.innerHeight,
docScrollW: de.scrollWidth, docScrollW: de.scrollWidth,
@@ -167,6 +225,42 @@ window.__measure = function () {
""" """
@cache
def authored_sideways() -> tuple[str, ...]:
"""Selectors whose rules really do ask for horizontal scrolling.
The tree's rule is that anything wide gets its own scroller, so these are
the correct ones: a table wrapper, a code block, the tab bar. Everything
else that scrolls sideways is `overflow-y: auto` dragging the other axis
along with it, which is always a bug and is what `.suggestions` did.
"""
selectors: list[str] = []
for path in sorted((STATIC / "css").glob("*.css")):
text = re.sub(r"/\*.*?\*/", "", path.read_text(), flags=re.S)
# Innermost blocks only: `[^{}]*` cannot cross a brace, so an `@media`
# prelude never matches and the rules inside it do.
for prelude, body in re.findall(r"([^{}]*)\{([^{}]*)\}", text):
wants = False
for declaration in body.split(";"):
name, _, value = declaration.partition(":")
name, value = name.strip().lower(), value.strip().lower()
if name not in ("overflow", "overflow-x") or not value:
continue
# `overflow: hidden auto` is x then y, so the first word is ours;
# `overflow: auto` is both.
wants = wants or value.split()[0] in ("auto", "scroll")
if not wants:
continue
selectors += [
part.strip()
for part in prelude.split(",")
if part.strip() and not part.strip().startswith("@")
]
if not selectors:
raise SystemExit("read no horizontal-overflow rules -- the sideways check would cry wolf")
return tuple(selectors)
def build_client(): def build_client():
import lembas.config as config_mod import lembas.config as config_mod
@@ -198,6 +292,17 @@ def build_client():
db.flush() db.flush()
for name in ("gemma4-moe", "qwen3-coder"): for name in ("gemma4-moe", "qwen3-coder"):
db.add(Model(connection_id=connection.id, model_id=name, display_name=name)) db.add(Model(connection_id=connection.id, model_id=name, display_name=name))
# 🚨 The suggestion cards are seeded by the startup hook, and `TestClient(app)`
# runs a lifespan only inside a `with` block -- so every shot of the new-chat
# screen ever taken by this script was of a page with its cards missing. That
# is how a grid 65px wider than a phone survived forty measurements. Seeded
# here rather than by entering the lifespan, which would also start the
# schedule ticker and rehydrate background jobs inside a screenshot run.
from lembas.services.suggestions import seed_defaults as seed_suggestions
with session_scope() as db:
seed_suggestions(db)
return client return client
@@ -219,6 +324,20 @@ def rewrite(html: str, client, assets: Path) -> str:
html, html,
) )
# Anything else the *application* serves rather than mounts. Model avatars live
# under `/uploads/models/…`, which is a route behind auth -- so they cannot be
# pointed at a file on disk and have to be fetched through the client like
# `/branding.css` above. A real instance has them and a fixture does not, which
# is exactly the difference that makes a page measured here unlike the page
# somebody is looking at.
for url in sorted({*re.findall(r'\bsrc="(/(?:uploads|branding)/[^"?]+)"', html)}):
response = client.get(url)
if response.status_code != 200:
continue
name = "fetched-" + url.strip("/").replace("/", "-")
(assets / name).write_bytes(response.content)
html = html.replace(f'src="{url}"', f'src="file://{assets}/{name}"')
# Fail loudly, and only about things that decide how the page LOOKS: every # Fail loudly, and only about things that decide how the page LOOKS: every
# `src`, and `href` on a <link>. An `href` on an anchor is a destination, # `src`, and `href` on a <link>. An `href` on an anchor is a destination,
# not an asset -- flagging those makes the guard cry wolf on every page and # not an asset -- flagging those makes the guard cry wolf on every page and
@@ -256,7 +375,8 @@ def rewrite(html: str, client, assets: Path) -> str:
"<script>try{localStorage.setItem('lembas-notifications-asked','1');}" "<script>try{localStorage.setItem('lembas-notifications-asked','1');}"
"catch(e){}</script>" "catch(e){}</script>"
) )
return html.replace("</head>", quiet + MEASURE + "</head>", 1) measure = MEASURE.replace("__SIDEWAYS_AUTHORS__", json.dumps(list(authored_sideways())))
return html.replace("</head>", quiet + measure + "</head>", 1)
def shoot(client, path: str, width: int, height: int, theme: str, outdir: Path) -> dict: def shoot(client, path: str, width: int, height: int, theme: str, outdir: Path) -> dict:
@@ -391,6 +511,13 @@ def main() -> None:
flags.append(f"DOC-SCROLLS({r['docScrollH']}>{r['innerH']})") flags.append(f"DOC-SCROLLS({r['docScrollH']}>{r['innerH']})")
if r["scrollsSideways"]: if r["scrollsSideways"]:
flags.append(f"SIDEWAYS({r['docScrollW']}>{r['innerW']})") flags.append(f"SIDEWAYS({r['docScrollW']}>{r['innerW']})")
for s in r.get("sidewaysScrollers", []):
widest = s["widest"]
blame = f"<{widest['tag']}.{widest['cls']} {widest['w']}px" if widest else ""
flags.append(
f"SCROLLER-SIDEWAYS({s['tag']}.{s['cls']} "
f"{s['scrollW']}>{s['clientW']}{blame})"
)
if r["overflowCount"]: if r["overflowCount"]:
flags.append(f"overflow:{r['overflowCount']}") flags.append(f"overflow:{r['overflowCount']}")
if r["smallCount"]: if r["smallCount"]:
+1 -1
View File
@@ -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.7.0" __version__ = "1.9.0"
-33
View File
@@ -66,10 +66,6 @@ async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = F
# reply is allowed to set going on its own, and a nav entry for one # reply is allowed to set going on its own, and a nav entry for one
# card would be worse than the near-miss. # card would be worse than the near-miss.
"subagents": settings_store.subagents(db), "subagents": settings_store.subagents(db),
# And a third group on the same page, for the same reason: a crowd is
# not an agent-chat feature either, but this is where somebody comes to
# find out what one turn is allowed to set going.
"crowd": settings_store.crowd(db),
"saved": saved, "saved": saved,
}, },
) )
@@ -115,35 +111,6 @@ async def save_subagents(
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/crowd")
async def save_crowd(
db: Db,
user: AdminUser,
enabled: bool = Form(False),
max_models: int = Form(4),
max_rounds: int = Form(2),
wall_seconds: int = Form(900),
collapse_agreement: bool = Form(False),
) -> Response:
"""Its own route, for the reason `save_subagents` gives above."""
settings_store.update(
db,
{
"enabled": enabled,
# Clamped here as well as on read. Every floor is one: a zero would be
# the feature switched off wearing the switch's clothes, and that is a
# thing to answer in one place.
"max_models": min(max(max_models, 1), 8),
"max_rounds": min(max(max_rounds, 1), 5),
"wall_seconds": min(max(wall_seconds, 60), 7200),
"collapse_agreement": collapse_agreement,
},
key=settings_store.CROWD,
)
log.info("crowd %s by %s", "enabled" if enabled else "disabled", user.email)
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("") @router.post("")
async def save_agents( async def save_agents(
db: Db, db: Db,
+1 -1
View File
@@ -19,7 +19,7 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/audio", tags=["admin-audio"]) router = APIRouter(prefix="/admin/audio", tags=["admin-audio"])
# Read out by the speech test. Short, and the one line this project would pick. # Read out by the speech test. Short, and the one line this project would pick.
TEST_PHRASE = "Speak, friend, and enter." TEST_PHRASE = "Speak friend and enter."
def _page_context(db: Db) -> dict: def _page_context(db: Db) -> dict:
+68
View File
@@ -0,0 +1,68 @@
"""The crowd: several models answering one turn, in any chat.
Its own module because it is its own page, and it is its own page because as a card
on `/admin/agents` it read as an agent-chat feature. It is not one: a crowd works in
an ordinary conversation, and the owner reasonably concluded otherwise from where
the switch was sitting.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.services import settings_store
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/crowd", tags=["admin-crowd"])
@router.get("")
async def crowd_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
"""Its own page, for the reason its template records: as a card on the Agents
screen it read as an agent-chat feature, which it is not."""
return render(
request,
"admin/crowd.html",
{"crowd": settings_store.crowd(db), "saved": saved},
)
@router.post("")
async def save_crowd(
db: Db,
user: AdminUser,
enabled: bool = Form(False),
max_models: int = Form(4),
max_rounds: int = Form(2),
wall_seconds: int = Form(900),
collapse_agreement: bool = Form(False),
) -> Response:
"""One group, one form, one route.
The bounds are clamped here as well as in `settings_store.crowd`, which is the
same belt-and-braces `save_subagents` in `admin_agents.py` uses: a value posted
past this route -- by an older page, or by hand -- still reads back sane.
"""
settings_store.update(
db,
{
"enabled": enabled,
# Every floor is one: a zero would be the feature switched off
# wearing the switch's clothes.
"max_models": min(max(max_models, 1), 8),
"max_rounds": min(max(max_rounds, 1), 5),
"wall_seconds": min(max(wall_seconds, 60), 7200),
"collapse_agreement": collapse_agreement,
},
key=settings_store.CROWD,
)
log.info("crowd %s by %s", "enabled" if enabled else "disabled", user.email)
return RedirectResponse("/admin/crowd?saved=1", status_code=status.HTTP_303_SEE_OTHER)
+48 -33
View File
@@ -296,6 +296,11 @@ async def start_chat(
scope_on: list[str] = Form(default=[]), scope_on: list[str] = Form(default=[]),
scope_skill_all: list[str] = Form(default=[]), scope_skill_all: list[str] = Form(default=[]),
scope_skill_on: list[str] = Form(default=[]), scope_skill_on: list[str] = Form(default=[]),
# Who else answers, as the crowd menu stood before the first word. There is no
# chat row yet to attach members to, so the choice rides along with the message
# -- the same mechanism the scope switches above use, and the reason the control
# lives inside the composer's form rather than in the topbar.
crowd_model_ids: list[str] = Form(default=[]),
) -> Response: ) -> Response:
"""Create a chat from its first message. """Create a chat from its first message.
@@ -329,6 +334,8 @@ async def start_chat(
skills_off=frozenset(scope_skill_all) - frozenset(scope_skill_on), skills_off=frozenset(scope_skill_all) - frozenset(scope_skill_on),
) )
_apply_crowd(db, chat, user, crowd_model_ids)
_adopt_draft(db, user, draft_id, chat) _adopt_draft(db, user, draft_id, chat)
user_message = chat_service.create_message(db, chat, ROLE_USER, content) user_message = chat_service.create_message(db, chat, ROLE_USER, content)
@@ -1510,6 +1517,45 @@ def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
} }
def _apply_crowd(db: DBSession, chat: Chat, user: User, values: list[str]) -> None:
"""Replace a chat's crowd with the models named, in the order named.
One implementation for both the composer (where the choice rides along with
the first message) and the settings panel, because two would be two places to
forget a rule -- and there are three:
* **Checked against what this person can reach**, never against what exists.
A control checked only in the template is advisory, and a crafted request
walks past it. Same reasoning as the model branch in `update_chat`.
* **Never the chat's own model**, which would answer twice in a row.
* **Capped by `crowd.max_models`**, on the way in as well as on the way out.
The connection is stored beside the id because `Model` is unique on the pair,
and a model offered by two connections is two rows with different capabilities.
"""
from lembas.db.models import CrowdMember
settings = settings_store.crowd(db)
reachable = {
model.model_id: model for model in chat_service.available_models(db, user)
}
wanted: list[str] = []
for value in values:
value = str(value).strip()
if value and value in reachable and value != chat.model_id and value not in wanted:
wanted.append(value)
wanted = wanted[: int(settings["max_models"])]
chat.crowd = [
CrowdMember(
model_id=model_id,
connection_id=reachable[model_id].connection_id,
position=index,
)
for index, model_id in enumerate(wanted)
]
def _messages_after(db: DBSession, message: Message) -> list[Message]: def _messages_after(db: DBSession, message: Message) -> list[Message]:
"""Everything later in this chat than one message. """Everything later in this chat than one message.
@@ -2117,39 +2163,8 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
if "crowd_model_ids" in form: if "crowd_model_ids" in form:
# The same shape as the bases above: one field always sent, so clearing # The same shape as the bases above: one field always sent, so clearing
# every box clears the crowd. Checked against what this person can reach # every box clears the crowd.
# rather than against what exists, or the picker is advisory and a crafted _apply_crowd(db, chat, user, form.getlist("crowd_model_ids"))
# request walks past it -- the reasoning the model branch carries.
from lembas.db.models import CrowdMember
settings = settings_store.crowd(db)
reachable = {
model.model_id for model in chat_service.available_models(db, user)
}
wanted: list[str] = []
for value in form.getlist("crowd_model_ids"):
value = str(value).strip()
# Never the chat's own model: it would answer twice in a row, which is
# nobody's idea of a second opinion.
if value and value in reachable and value != chat.model_id and value not in wanted:
wanted.append(value)
wanted = wanted[: int(settings["max_models"])]
chat.crowd = [
CrowdMember(
model_id=model_id,
connection_id=next(
(
model.connection_id
for model in chat_service.available_models(db, user)
if model.model_id == model_id
),
None,
),
position=index,
)
for index, model_id in enumerate(wanted)
]
submitted_params = {name: form[name] for name in _PARAM_RANGES if name in form} submitted_params = {name: form[name] for name in _PARAM_RANGES if name in form}
if submitted_params: if submitted_params:
+23
View File
@@ -0,0 +1,23 @@
"""What the model menu asks for when it opens."""
from __future__ import annotations
from fastapi import APIRouter
from lembas.api.deps import Db, RequiredUser
from lembas.services import chat as chat_service
from lembas.services import model_state
router = APIRouter(prefix="/api/models", tags=["models"])
@router.get("/state")
async def model_states(db: Db, user: RequiredUser) -> dict:
"""`{"states": {model_id: "loaded" | "loading" | "unloaded"}}`.
Only models this reader may use, so the answer never names a model the
menu would not show. Only those whose endpoint reports a state, so a hosted
API's models are simply absent. See `services/model_state.py`.
"""
models = chat_service.available_models(db, user)
return {"states": await model_state.states_for(models)}
+38 -9
View File
@@ -83,7 +83,9 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
else [] else []
), ),
"attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [], "attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
**_crowd_context(db, user, chat, models), **_crowd_context(
db, user, chat, models, current.model_id if current is not None else ""
),
# What *this* model takes, not the three every model used to be assumed # What *this* model takes, not the three every model used to be assumed
# to take. The vocabulary is per model -- gpt-oss has no `xhigh` and # to take. The vocabulary is per model -- gpt-oss has no `xhigh` and
# Bonsai has no `high`, and sending the wrong one does not degrade, it # Bonsai has no `high`, and sending the wrong one does not degrade, it
@@ -197,12 +199,17 @@ def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict:
} }
def _crowd_context(db: DBSession, user: User, chat: Chat | None, models: list) -> dict: def _crowd_context(
db: DBSession, user: User, chat: Chat | None, models: list, default_model_id: str = ""
) -> dict:
"""Who else could answer in this chat, and what that would cost. """Who else could answer in this chat, and what that would cost.
Empty — and the panel then shows nothing rather than an empty control — when Offered on the **new-chat screen as well**, where there is no chat row yet: the
the feature is off, when there is nobody else to add, or on the new-chat choice rides along with the first message, the way the scope switches do. The
screen, where there is no chat to attach anybody to yet. first version of this was per-chat only and therefore invisible to anybody
setting a conversation up — which is how the feature shipped switched on and
unreachable. Empty only when the feature is off or there is nobody else to add,
and then the control is absent rather than being an empty menu.
The cost is spelled out because it is the thing somebody will not have thought The cost is spelled out because it is the thing somebody will not have thought
about: a turn is `speakers x rounds x 2 - 1` replies, and on one local endpoint about: a turn is `speakers x rounds x 2 - 1` replies, and on one local endpoint
@@ -211,21 +218,31 @@ def _crowd_context(db: DBSession, user: User, chat: Chat | None, models: list) -
from lembas.services import crowd as crowd_service from lembas.services import crowd as crowd_service
settings = settings_store.crowd(db) settings = settings_store.crowd(db)
if chat is None or not settings["enabled"]: if not settings["enabled"]:
return {"crowd_available": [], "crowd_member_ids": [], "crowd_skipped": []} return {"crowd_available": [], "crowd_member_ids": [], "crowd_skipped": []}
others = [model for model in models if model.model_id != chat.model_id] # On the new-chat screen the "own" model is whichever one the picker is
members = [ # showing, so the list excludes it for the same reason it does in a chat:
# adding it would have it answer twice in a row.
own = chat.model_id if chat is not None else default_model_id
others = [model for model in models if model.model_id != own]
members = (
[
row.model_id row.model_id
for row in sorted(chat.crowd, key=lambda row: (row.position, row.model_id)) for row in sorted(chat.crowd, key=lambda row: (row.position, row.model_id))
] ]
if chat is not None
else []
)
reachable = {model.model_id for model in others} reachable = {model.model_id for model in others}
speakers = 1 + len([model_id for model_id in members if model_id in reachable]) speakers = 1 + len([model_id for model_id in members if model_id in reachable])
rounds = int(settings["max_rounds"]) rounds = int(settings["max_rounds"])
return { return {
"crowd_available": others, "crowd_available": others,
"crowd_member_ids": [model_id for model_id in members if model_id in reachable], "crowd_member_ids": [model_id for model_id in members if model_id in reachable],
"crowd_skipped": crowd_service.unreachable_members(db, chat, user), "crowd_skipped": (
crowd_service.unreachable_members(db, chat, user) if chat is not None else []
),
# One round is out and back: everybody answers, everybody but the last is # One round is out and back: everybody answers, everybody but the last is
# asked whether they disagree, and the main model closes. # asked whether they disagree, and the main model closes.
"crowd_replies": max(1, speakers * 2 - 1), "crowd_replies": max(1, speakers * 2 - 1),
@@ -719,6 +736,18 @@ async def chat_index(
"bodies": {}, "bodies": {},
**context, **context,
"current_model": preselected, "current_model": preselected,
# `_chat_context` reads the efforts off the *chat's* model, and there
# is no chat here -- so every new chat was offered the generic three
# whatever it was about to talk to. On Bonsai (low, medium, xhigh)
# the configured `xhigh` was not among them, and the picker fell
# through to "off". The chat created from this screen then got
# `xhigh` anyway, so the control said one thing and the first reply
# did another.
"efforts": (
chat_service.efforts_for(preselected)
if preselected
else chat_service.DEFAULT_EFFORTS
),
"starting_temporary": temporary, "starting_temporary": temporary,
"starting_kind": kind if kind in KINDS else KIND_CHAT, "starting_kind": kind if kind in KINDS else KIND_CHAT,
"starting_folder": starting_folder, "starting_folder": starting_folder,
+4
View File
@@ -17,6 +17,7 @@ from lembas.api import (
admin_agents, admin_agents,
admin_audio, admin_audio,
admin_branding, admin_branding,
admin_crowd,
admin_extraction, admin_extraction,
admin_images, admin_images,
admin_models, admin_models,
@@ -37,6 +38,7 @@ from lembas.api import (
folders, folders,
library, library,
messages, messages,
models,
pages, pages,
preferences, preferences,
push, push,
@@ -203,6 +205,7 @@ def create_app() -> FastAPI:
app.include_router(folders.router) app.include_router(folders.router)
app.include_router(library.router) app.include_router(library.router)
app.include_router(messages.router) app.include_router(messages.router)
app.include_router(models.router)
app.include_router(reports.router) app.include_router(reports.router)
app.include_router(schedules.router) app.include_router(schedules.router)
app.include_router(agents.router) app.include_router(agents.router)
@@ -221,6 +224,7 @@ def create_app() -> FastAPI:
app.include_router(admin_suggestions.router) app.include_router(admin_suggestions.router)
app.include_router(admin_tools.router) app.include_router(admin_tools.router)
app.include_router(admin_agents.router) app.include_router(admin_agents.router)
app.include_router(admin_crowd.router)
app.include_router(push.router) app.include_router(push.router)
app.include_router(branding.router) app.include_router(branding.router)
+6 -2
View File
@@ -71,7 +71,11 @@ FLAVOUR: dict[str, tuple[str, str, str]] = {
"chat_empty": ( "chat_empty": (
"Empty chat", "Empty chat",
"Above the composer on a chat with nothing in it yet.", "Above the composer on a chat with nothing in it yet.",
"Speak, friend, and enter.", # No commas, on purpose. It is the riddle on the Doors of Durin, and
# its answer is to *say* "friend" -- the password is the word itself.
# With commas it is an invitation to a friend, which is the misreading
# that kept the Fellowship outside the door.
"Speak friend and enter.",
), ),
"offline_title": ( "offline_title": (
"Offline heading", "Offline heading",
@@ -87,7 +91,7 @@ FLAVOUR: dict[str, tuple[str, str, str]] = {
"error_403": ( "error_403": (
"403 — not yours", "403 — not yours",
"Shown on a page somebody is not allowed to see.", "Shown on a page somebody is not allowed to see.",
"Speak, friend, and enter. This door is not yours to open.", "Speak friend and enter. This door is not yours to open.",
), ),
"error_404": ( "error_404": (
"404 — not found", "404 — not found",
+4 -1
View File
@@ -588,7 +588,10 @@ def build_request(
if crowd_turn is None and upto is not None: if crowd_turn is None and upto is not None:
from lembas.services import crowd as crowd_service from lembas.services import crowd as crowd_service
crowd_turn = crowd_service.state_of(upto) # `scheduling_state`: the opening reply carries a stamp for the chip's
# sake, and regenerating it must still build an ordinary first answer --
# not one told that "the answers above are quoted, yours comes next".
crowd_turn = crowd_service.scheduling_state(upto)
# Images are only sent to a model an administrator has marked as having # Images are only sent to a model an administrator has marked as having
# vision. Sending them to one that has not is not a graceful degradation: # vision. Sending them to one that has not is not a graceful degradation:
# most endpoints reject the whole request. # most endpoints reject the whole request.
+37
View File
@@ -134,6 +134,41 @@ def state_of(message: Message | None) -> Turn | None:
return None return None
def is_opening(state: Turn | None) -> bool:
"""Whether this state is the main model's opening reply.
`phase=out, index=0` is **display state and never scheduling state**. The
opening reply is not started by the crowd -- the composer starts it, exactly
as it starts every other reply, and a round only begins when it *finishes*.
Stamping it afterwards is what lets the transcript say `1 of 3` on the bubble
that opened the round; before that it was the one contribution with no chip,
so a two-model round read as an ordinary reply followed by a crowd.
Everything that asks "is a round already in progress?" has to skip it, or the
stamp changes behaviour it was never meant to touch -- see `scheduling_state`.
"""
return state is not None and state.phase == PHASE_OUT and state.index == 0
def scheduling_state(message: Message | None) -> Turn | None:
"""The round state the scheduler should act on: `state_of`, minus the opening.
Two things would break if the opening stamp were fed to `next_turn` as real
state, and both are silent:
* **`started_at` would be inherited on a regenerate.** Regenerating the
opening reply an hour later would hand `next_turn` an hour-old clock and the
round would stop with "out of time" before anybody spoke.
* **The once-per-turn gates key off "no state at all"** -- compaction, the
title, the unread push. A stamped opening reads as a later speaker, and each
of them would be skipped for the turn that is supposed to have them.
So the stamp is written where the transcript reads it and nowhere else.
"""
state = state_of(message)
return None if is_opening(state) else state
def now_stamp() -> str: def now_stamp() -> str:
return datetime.now(UTC).isoformat() return datetime.now(UTC).isoformat()
@@ -374,9 +409,11 @@ __all__ = [
"Turn", "Turn",
"elapsed", "elapsed",
"is_newest", "is_newest",
"is_opening",
"member_speakers", "member_speakers",
"next_turn", "next_turn",
"now_stamp", "now_stamp",
"scheduling_state",
"state_of", "state_of",
"tool_defs", "tool_defs",
"unreachable_members", "unreachable_members",
+32 -5
View File
@@ -661,7 +661,10 @@ async def _run(generation: Generation) -> None:
# once, here, and used for three decisions: which tools it may have, # once, here, and used for three decisions: which tools it may have,
# which instruction closes its request, and whether it may ask for # which instruction closes its request, and whether it may ask for
# another round. # another round.
crowd_state = crowd_service.state_of(message) # `scheduling_state` for the reason `build_request` gives: the
# opening reply's stamp is for the transcript, and regenerating it
# must not hand it a member's tools or a member's instruction.
crowd_state = crowd_service.scheduling_state(message)
crowd_settings = settings_store.crowd(db) crowd_settings = settings_store.crowd(db)
may_ask_again = bool( may_ask_again = bool(
crowd_state is not None crowd_state is not None
@@ -2232,7 +2235,11 @@ def _advance_crowd(generation: Generation) -> bool:
speakers = crowd_service.member_speakers(db, chat, owner_user) speakers = crowd_service.member_speakers(db, chat, owner_user)
speakers = speakers[: int(settings["max_models"]) + 1] speakers = speakers[: int(settings["max_models"]) + 1]
state = crowd_service.state_of(message) # `scheduling_state` and not `state_of`: the opening reply carries a
# stamp for the transcript's sake (so it can say `1 of 3`), and that
# stamp must not read as "a round is already running" -- it would
# inherit the old clock on a regenerate. See `crowd.is_opening`.
state = crowd_service.scheduling_state(message)
# The turn a round belongs to: the user message this all answers. # The turn a round belongs to: the user message this all answers.
turn_id = state.turn if state is not None else _turn_anchor(db, message) turn_id = state.turn if state is not None else _turn_anchor(db, message)
following = crowd_service.next_turn( following = crowd_service.next_turn(
@@ -2254,6 +2261,22 @@ def _advance_crowd(generation: Generation) -> bool:
db.commit() db.commit()
return False return False
if state is None:
# The round begins here, so stamp the reply that opened it. It is
# the only contribution that is not started by the crowd, and
# before this it was the only one with no chip -- which made a
# two-model round read as an ordinary reply followed by a crowd,
# and left the reader counting "2 of 2" with no 1 in sight. Same
# turn and same `started_at`, so the bubbles group.
message.crowd_json = crowd_service.Turn(
turn=following.turn,
round=following.round,
phase=crowd_service.PHASE_OUT,
index=0,
of=following.of,
started_at=following.started_at,
).as_json()
speaker = speakers[following.index] speaker = speakers[following.index]
placeholder = chat_service.create_message( placeholder = chat_service.create_message(
db, db,
@@ -2281,10 +2304,14 @@ def _opens_the_turn(message: Message) -> bool:
"""Whether this reply is the first one answering a question. """Whether this reply is the first one answering a question.
True for every ordinary reply, and for a crowd only for the main model's True for every ordinary reply, and for a crowd only for the main model's
opening turn -- which is the one with no crowd state on it at all, because a opening turn. That reply has no crowd state while it is being written -- a
round begins when that reply *finishes*. round begins when it *finishes* -- and once the round has begun it carries the
opening stamp, which `is_opening` reads as "still the one that opens the
turn". Both are the same answer to this question, and missing the second means
a reply that has already been compacted-for and titled gets it again on the
next look.
""" """
return crowd_service.state_of(message) is None return crowd_service.scheduling_state(message) is None
def _opens_the_turn_id(generation: Generation) -> bool: def _opens_the_turn_id(generation: Generation) -> bool:
+114
View File
@@ -0,0 +1,114 @@
"""Which models are loaded right now, where the endpoint is able to say.
llama-swap holds one model at a time and reports which, inside the ordinary
`GET /v1/models` answer: every entry carries `"status": {"value": "loaded"}`
or `"unloaded"`. Choosing a model that is not loaded costs a load (seconds for
a small one, most of a minute for the 26B), so the model menu shows a dot on
the one that is ready.
**Only what an endpoint states, and nothing inferred.** The OpenAI spec has
no such field. A hosted API such as DeepSeek leaves it out because nothing is
ever unloaded there, so its models get no state and no dot, rather than a
guess dressed up as a reading. The same shape covers the next runner that
reports it: `status` as an object with `value`, or as a bare string.
**Cheap by construction**, because the menu asks every time it opens:
- one `/v1/models` per *connection*, not per model, all at once;
- a short timeout, because a slow endpoint must never hold up a menu;
- five seconds of cache per connection, so opening the menu repeatedly costs
one request;
- and ten minutes for a connection that said nothing about state, so a hosted
API is not asked for its model list on every click only to answer nothing
again.
Process-level, like the branding cache. With several workers each keeps its
own, which costs at most one extra request each and cannot be wrong for longer
than the TTL.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
from lembas.services.llm.openai_client import Endpoint, list_models
log = logging.getLogger(__name__)
TIMEOUT = 3.0
TTL = 5.0
TTL_SILENT = 600.0
LOADED = "loaded"
LOADING = "loading"
UNLOADED = "unloaded"
_LOADED_WORDS = frozenset({"loaded", "ready", "running"})
_LOADING_WORDS = frozenset({"loading", "starting"})
# connection id -> (monotonic time read, TTL, {model_id: state})
_CACHE: dict[str, tuple[float, float, dict[str, str]]] = {}
def state_of(entry: dict[str, Any]) -> str:
"""One `/v1/models` entry's state, or "" when it states none."""
status = entry.get("status")
value = status.get("value") if isinstance(status, dict) else status
if not isinstance(value, str) or not value.strip():
return ""
word = value.strip().lower()
if word in _LOADED_WORDS:
return LOADED
if word in _LOADING_WORDS:
return LOADING
return UNLOADED
async def _read(connection) -> dict[str, str]:
now = time.monotonic()
cached = _CACHE.get(connection.id)
if cached and now - cached[0] < cached[1]:
return cached[2]
try:
entries = await asyncio.wait_for(
list_models(Endpoint.from_connection(connection)), TIMEOUT
)
except Exception: # noqa: BLE001 - an unreachable endpoint has no state, not an error page
log.debug("model state unavailable for %s", connection.name, exc_info=True)
# Not cached: the next open asks again, which is right for an endpoint
# that is merely starting up.
return {}
states = {entry["id"]: state for entry in entries if (state := state_of(entry))}
_CACHE[connection.id] = (now, TTL if states else TTL_SILENT, states)
return states
async def states_for(models) -> dict[str, str]:
"""`{model_id: state}` for the models whose endpoint reports one.
Models without a stated state are absent, not `""`, so the page can treat
"no key" as "draw nothing".
"""
connections = {}
for model in models:
connection = getattr(model, "connection", None)
if connection is not None and connection.enabled:
connections[connection.id] = connection
if not connections:
return {}
results = await asyncio.gather(*(_read(c) for c in connections.values()))
by_connection = dict(zip(connections, results, strict=True))
out: dict[str, str] = {}
for model in models:
state = by_connection.get(model.connection_id, {}).get(model.model_id)
if state:
out[model.model_id] = state
return out
def forget() -> None:
"""Drop the cache. For tests."""
_CACHE.clear()
+38 -8
View File
@@ -2023,17 +2023,28 @@ BUILTIN: tuple[Fragment, ...] = (
group=GROUP_TASKS, group=GROUP_TASKS,
order=451, order=451,
hint="Added as the last turn when a member speaks on the forward pass. " hint="Added as the last turn when a member speaks on the forward pass. "
"The failure to word against is a member that repeats what has already " "Two failures to word against. One is a member that repeats what has "
"been said in different words, which is what makes a crowd feel like an " "already been said in different words, which makes a crowd an echo "
"echo rather than a second opinion.", "rather than a second opinion. The other only shows up on a request that "
"asks for something to be *made* -- write this, pick one, draft that -- "
"where a member reads the original instruction as addressed to it too "
"and produces a rival answer beside its critique. That is not a second "
"opinion either; it is two first opinions, and it is what sends a round "
"off the question.",
default=( default=(
"You are one of several models answering this. The answers above are " "You are one of several models answering this. The answers above are "
"quoted with the name of whoever wrote them; yours comes next.\n" "quoted with the name of whoever wrote them; yours comes next.\n"
"\n" "\n"
"Respond to what is above you. Do not answer the person's original "
"request again yourself — that has been done, and your turn is about "
"what was done with it.\n"
"\n"
"Add what is missing, correct what is wrong, and say what you would " "Add what is missing, correct what is wrong, and say what you would "
"have done differently. Do not restate what has already been said to " "have done differently and why. Where you would have made a different "
"show that you agree with it — if you have nothing to add, say so in " "choice, say what it would buy — naming an alternative is not the same "
"one line and stop. Be brief: somebody is reading all of these." "as giving a reason to prefer it. Do not restate what has already been "
"said to show that you agree with it — if you have nothing to add, say "
"so in one line and stop. Be brief: somebody is reading all of these."
), ),
), ),
Fragment( Fragment(
@@ -2066,11 +2077,23 @@ BUILTIN: tuple[Fragment, ...] = (
"round. Its own fragment rather than a sentence inside the one below, " "round. Its own fragment rather than a sentence inside the one below, "
"because inviting a choice a model cannot express is worse than not " "because inviting a choice a model cannot express is worse than not "
"offering it: on a model without the tools capability there is no " "offering it: on a model without the tools capability there is no "
"crowd_again to call, and that is the case the next fragment covers.", "crowd_again to call, and that is the case the next fragment covers.\n"
"\n"
"The failure to word against is capitulation: the model that opened the "
"round abandoning its own answer because somebody spoke after it. A "
"closing turn told only to synthesise will follow the last speaker, "
"which is how a crowd ends up less accurate than the model that started "
"it.",
default=( default=(
"You opened this and you are closing it. The others have answered and " "You opened this and you are closing it. The others have answered and "
"have had the chance to disagree.\n" "have had the chance to disagree.\n"
"\n" "\n"
"Your own answer is not automatically the worse one for having been "
"written first. Change your position where somebody gave you a reason, "
"and say what the reason was; agreement with no argument behind it is "
"not a reason, and neither is a member having moved on to something "
"else.\n"
"\n"
"Write the answer the person actually asked for. Take what the others " "Write the answer the person actually asked for. Take what the others "
"got right, say where you disagree with them and why, and name " "got right, say where you disagree with them and why, and name "
"anything still unresolved rather than papering over it. Attribute " "anything still unresolved rather than papering over it. Attribute "
@@ -2091,11 +2114,18 @@ BUILTIN: tuple[Fragment, ...] = (
"is reached, or this model has no tools and so cannot ask. It says the " "is reached, or this model has no tools and so cannot ask. It says the "
"answer has to be final rather than inviting a choice that would be " "answer has to be final rather than inviting a choice that would be "
"ignored, which is the difference between a feature and a feature that " "ignored, which is the difference between a feature and a feature that "
"looks like one.", "looks like one. It carries the same guard against capitulation as the "
"fragment above, and for the same reason.",
default=( default=(
"You opened this and you are closing it, and this is the last turn: " "You opened this and you are closing it, and this is the last turn: "
"there will be no further round.\n" "there will be no further round.\n"
"\n" "\n"
"Your own answer is not automatically the worse one for having been "
"written first. Change your position where somebody gave you a reason, "
"and say what the reason was; agreement with no argument behind it is "
"not a reason, and neither is a member having moved on to something "
"else.\n"
"\n"
"Write the answer the person actually asked for. Take what the others " "Write the answer the person actually asked for. Take what the others "
"got right, say where you disagree with them and why, and attribute " "got right, say where you disagree with them and why, and attribute "
"what you took from whom. Where the disagreement is unresolved, say so " "what you took from whom. Where the disagreement is unresolved, say so "
+19 -5
View File
@@ -679,6 +679,10 @@ 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",
"Loaded": "Načítaný",
"Loading": "Načítava sa",
"Groups": "Skupiny", "Groups": "Skupiny",
"Members": "Členovia", "Members": "Členovia",
"Account": "Účet", "Account": "Účet",
@@ -1075,13 +1079,23 @@ MESSAGES.update(
"potom nemôže držať kolo otvorené celé poobedie." "potom nemôže držať kolo otvorené celé poobedie."
), ),
"Fold away a short \"I agree\" on the way back": ( "Fold away a short \"I agree\" on the way back": (
"Zbaliť krátke „súhlasím“ na cestě späť" "Zbaliť krátke „súhlasím“ na ceste späť"
), ),
"Off by default. With it on, each chat's settings panel offers the other models; a chat with none ticked behaves exactly as it always has.": ( "Off by default. With it on, every chat's composer offers the other models; a chat with none ticked behaves exactly as it always has.": (
"Predvolene vypnuté. Po zapnutí panel nastavení každej konverzácie ponúka " "Predvolene vypnuté. Po zapnutí ponúka pole na písanie v každej "
"ostatné modely; konverzácia bez zaškrtnutého modelu sa chová presne ako " "konverzácii ostatné modely; konverzácia bez zaškrtnutého modelu sa chová "
"vždy." "presne ako vždy."
), ),
"%(models)s models answer each turn, over up to %(rounds)s rounds.": (
"Na každý ťah odpovedá %(models)s modelov, a to najviac v %(rounds)s kolách."
),
"%(n)s of %(total)s": "%(n)s z %(total)s",
"on the way back": "na ceste späť",
"closing": "uzatvára",
"round %(n)s": "kolo %(n)s",
"no rounds left": "už žiadne kolá",
"out of time": "vypršal čas",
"two endpoints failed": "dva endpointy zlyhali",
"Check for due work every": "Kontrolovať splatnú prácu každých", "Check for due work every": "Kontrolovať splatnú prácu každých",
"How often it looks": "Ako často sa pozerá", "How often it looks": "Ako často sa pozerá",
"Nothing may repeat faster than": "Nič sa nesmie opakovať častejšie než", "Nothing may repeat faster than": "Nič sa nesmie opakovať častejšie než",
+43 -4
View File
@@ -167,16 +167,24 @@ a.tabs__tab { text-decoration: none; }
pinned to the scrollport with `background-attachment: local`, which is the old pinned to the scrollport with `background-attachment: local`, which is the old
trick and works everywhere -- the `local` layers scroll with the content and 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 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 { .tabs__bar {
background-image: background-image:
linear-gradient(to right, var(--bg) 40%, transparent), linear-gradient(to right, var(--bg) 50%, transparent),
linear-gradient(to left, var(--bg) 40%, transparent), linear-gradient(to left, var(--bg) 50%, transparent),
linear-gradient(to right, var(--scrim), transparent 1.5rem), linear-gradient(to right, var(--scrim), transparent 1.5rem),
linear-gradient(to left, 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-position: left center, right center, left center, right center;
background-repeat: no-repeat; 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; background-attachment: local, local, scroll, scroll;
/* A tab is a destination, so a flick should land on one rather than between /* A tab is a destination, so a flick should land on one rather than between
two. */ two. */
@@ -260,7 +268,9 @@ a.tabs__tab { text-decoration: none; }
*/ */
.field-row { .field-row {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); /* Both halves of the pair -- see `.grid--2` in app.css. */
min-width: 0;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr));
gap: var(--sp-3); gap: var(--sp-3);
} }
.field-row > .field { margin-bottom: var(--sp-4); } .field-row > .field { margin-bottom: var(--sp-4); }
@@ -449,6 +459,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;
+92 -3
View File
@@ -384,8 +384,16 @@ input.visually-hidden[type="checkbox"] {
/* Multi-column form layout, one definition. */ /* Multi-column form layout, one definition. */
.grid { display: grid; gap: var(--sp-4); } .grid { display: grid; gap: var(--sp-4); }
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); } /* `min(100%, …)` on every auto-fit track and `min-width: 0` with it, for the
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); } reason `.suggestions` in chat.css sets out at length. The pair is not
optional: `min(100%, …)` stops the track demanding more than the box, and
`min-width: 0` stops the *box* demanding more than its parent -- a grid or
flex item carries `min-width: auto`, which is a min-content floor, and a
floor beats `width`. A stylesheet cannot tell whether one of these grids has
been dropped into a flex parent today, so both go on every one of them.
`tests/test_narrow_grids.py` refuses a track that has only half the pair. */
.grid--2 { min-width: 0; grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); }
.grid--3 { min-width: 0; grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr)); }
/* --- Alerts --------------------------------------------------------------- */ /* --- Alerts --------------------------------------------------------------- */
.alert { .alert {
@@ -444,7 +452,17 @@ input.visually-hidden[type="checkbox"] {
child will not shrink below its content without it, so a scroller missing it 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 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 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, .scroll-region,
.sidebar__scroll, .sidebar__scroll,
.inspector__body, .inspector__body,
@@ -452,6 +470,7 @@ input.visually-hidden[type="checkbox"] {
.thread-scroll, .thread-scroll,
.admin-scroll, .admin-scroll,
.main > .tabs > .tabs__body { .main > .tabs > .tabs__body {
position: relative;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
@@ -1369,6 +1388,23 @@ body.is-resizing .canvas__body { pointer-events: none; }
which nothing else on the screen tells you -- gets the room back. */ which nothing else on the screen tells you -- gets the room back. */
.topbar__actions .picker__label { display: none; } .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 { .sidebar {
position: fixed; position: fixed;
inset: 0 auto 0 0; inset: 0 auto 0 0;
@@ -1722,6 +1758,59 @@ 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; }
/* Whether a model is loaded, where its endpoint says so (llama-swap does; a
hosted API does not, and gets nothing). A dot on the avatar's corner, ringed
in the menu's own surface so it reads against any avatar colour. Nothing is
drawn until ui.js has an answer -- an empty `data-model-state` is "unknown",
which is not the same claim as "unloaded". */
.model-slot { position: relative; display: flex; flex: none; }
.model-state {
position: absolute;
right: calc(var(--model-state-size) / -3);
bottom: calc(var(--model-state-size) / -3);
width: var(--model-state-size);
height: var(--model-state-size);
border-radius: var(--radius-full);
box-shadow: 0 0 0 var(--outline-w) var(--surface);
display: none;
}
.model-slot[data-model-state="loaded"] .model-state { display: block; background: var(--model-state-loaded); }
.model-slot[data-model-state="loading"] .model-state {
display: block;
background: var(--model-state-loading);
animation: model-state-pulse var(--dur-slow) var(--ease-in-out) infinite;
}
@keyframes model-state-pulse { 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) {
.model-slot[data-model-state="loading"] .model-state { animation: none; }
}
.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;
+30 -2
View File
@@ -266,7 +266,27 @@
*/ */
.suggestions { .suggestions {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); /* 🚨 `min-width: 0` is what keeps this grid on the screen, and `width: 100%`
alone did not: it is a grid item of `.thread__intro`, so it carries
`min-width: auto`, which for a grid item means *a min-content floor* -- and
min-width beats width. Its min-content size is two cards side by side, so it
rendered 428px wide inside a 390px phone with `width: 100%` set and ignored.
That floor is also why writing the track as `minmax(min(100%, 13rem), 1fr)`
-- the tree's standing rule, and right -- made it *worse* on its own, 428px
to 455px: a percentage is indefinite while the floor is being measured, so
the track fell back to a card's max-content and raised the very number that
was overflowing. The two go together. With the floor removed, `width: 100%`
finally resolves against the 366px column, `min(100%, …)` hands the track
366px to clamp against, and `auto-fit` places one column.
It scrolled `.thread-scroll` rather than the page, which is why a pass
looking for a document that scrolls sideways never saw it: `overflow-y: auto`
makes the other axis scrollable too. Reported on a phone, found by asking
which *element* could scroll and then reading its computed `width` against
its parent's. */
min-width: 0;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
gap: var(--sp-3); gap: var(--sp-3);
width: 100%; width: 100%;
max-width: 40rem; max-width: 40rem;
@@ -983,9 +1003,17 @@
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
} }
/* position: relative anchors the `@` and `/` menu to the box. */ /* position: relative anchors the `@` and `/` menu to the box.
`width: 100%` is the width; `max-width` only caps it. Without it the box was
as wide as its widest content: `.composer` is a flex column, and auto margins
on a flex item switch off the stretch it would otherwise get. So the hint
under it decided. "GPT-OSS has no vision, so images will not be sent" made
the box 768px, and the same screen with a model that sees images made it
538px. */
.composer__inner { .composer__inner {
position: relative; position: relative;
width: 100%;
max-width: var(--thread-max-width); max-width: var(--thread-max-width);
margin: 0 auto; margin: 0 auto;
} }
+8
View File
@@ -50,6 +50,14 @@
--radius-xl: 18px; --radius-xl: 18px;
--radius-full: 999px; --radius-full: 999px;
/* The load-state dot on a model's avatar in the model menu. Its colours are
tokens of their own, defaulting to the theme's success and warning, so
an instance whose success colour is not green can still say "loaded" in
green -- that is what people read a dot beside a name as. */
--model-state-size: 0.625rem;
--model-state-loaded: var(--success);
--model-state-loading: var(--warning);
/* /*
--- Controls ---------------------------------------------------------- --- Controls ----------------------------------------------------------
Every button, input and select resolves its height from these. That is the Every button, input and select resolves its height from these. That is the
+58 -19
View File
@@ -1099,27 +1099,59 @@
The worker no longer takes over open pages on its own -- see sw.js -- so 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 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 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.
function watchForUpdate(registration) {
function offer(worker) { 🚨 "A worker is waiting" is not the same as "this page is out of date",
if (!worker || !navigator.serviceWorker.controller) return; and the toast used to treat them as one. After a release it offered a
worker.addEventListener("statechange", function () { reload on every page, including one just fetched with Ctrl+Shift+R, and
if (worker.state !== "installed") return; 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( window.lembas.notify(
"A new version is ready. Reload to use it.", "A new version is ready. Reload to use it.",
{ kind: "info", action: { label: "Reload", run: function () { { kind: "info", action: { label: "Reload", run: function () {
worker.postMessage({ type: "SKIP_WAITING" }); 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;
if (isNewerThanThisPage(worker)) offerReload(worker);
}); });
} }
if (registration.waiting && navigator.serviceWorker.controller) { if (registration.waiting && navigator.serviceWorker.controller &&
window.lembas.notify( isNewerThanThisPage(registration.waiting)) {
"A new version is ready. Reload to use it.", offerReload(registration.waiting);
{ kind: "info", action: { label: "Reload", run: function () {
registration.waiting.postMessage({ type: "SKIP_WAITING" });
} } }
);
} }
registration.addEventListener("updatefound", function () { registration.addEventListener("updatefound", function () {
offer(registration.installing); offer(registration.installing);
@@ -1130,17 +1162,24 @@
the right answer to it -- the page is now being served by a worker whose the right answer to it -- the page is now being served by a worker whose
cache it did not start from. cache it did not start from.
Two guards, and the second is the one that is easy to miss. A flag, because Three guards, and the second is the one that is easy to miss. A flag,
`controllerchange` can fire more than once. And `hadController`, because on because `controllerchange` can fire more than once. And `hadController`,
a *first* visit there is no worker at all: the one that installs then calls because on a *first* visit there is no worker at all: the one that
`clients.claim()`, which fires this event for the first time -- so without installs then calls `clients.claim()`, which fires this event for the
it, the very first page anybody loads reloads itself in front of them for first time -- so without it, the very first page anybody loads reloads
no reason they could possibly work out. */ 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; var reloading = false;
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
var hadController = !!navigator.serviceWorker.controller; var hadController = !!navigator.serviceWorker.controller;
navigator.serviceWorker.addEventListener("controllerchange", function () { navigator.serviceWorker.addEventListener("controllerchange", function () {
if (reloading || !hadController) return; if (reloading || !hadController) return;
var controller = navigator.serviceWorker.controller;
if (controller && !isNewerThanThisPage(controller)) return;
reloading = true; reloading = true;
window.location.reload(); window.location.reload();
}); });
+57 -1
View File
@@ -320,6 +320,11 @@
if (filter) { if (filter) {
filter.value = ""; filter.value = "";
applyFilter(menu, ""); 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(); filter.focus();
} else { } else {
var selected = menu.querySelector(".picker__option.is-selected") || var selected = menu.querySelector(".picker__option.is-selected") ||
@@ -329,6 +334,45 @@
// Keep the chosen model in view when the list is long. // Keep the chosen model in view when the list is long.
var current = menu.querySelector(".picker__option.is-selected"); var current = menu.querySelector(".picker__option.is-selected");
if (current) current.scrollIntoView({ block: "nearest" }); if (current) current.scrollIntoView({ block: "nearest" });
refreshStates(menu);
}
/* Which models are loaded, asked for each time the model menu opens --
llama-swap holds one at a time and it changes by the minute, so a value
rendered with the page would be stale by the time anybody looked. Only
models whose endpoint reports a state come back; everything else keeps an
empty `data-model-state`, which draws nothing. While one is loading the
menu asks again every two seconds, and stops when it closes. */
function refreshStates(menu) {
var list = menu.querySelector(".picker__list--models");
if (!list || !window.fetch) return;
clearTimeout(menu._stateTimer);
fetch("/api/models/state", {
credentials: "same-origin",
headers: { Accept: "application/json" }
}).then(function (response) {
return response.ok ? response.json() : null;
}).then(function (data) {
var states = (data && data.states) || {};
var loading = false;
list.querySelectorAll(".picker__option[data-model-id]").forEach(function (option) {
var slot = option.querySelector("[data-model-state]");
if (!slot) return;
var state = states[option.dataset.modelId] || "";
slot.dataset.modelState = state;
if (state === "loading") loading = true;
var label = slot.querySelector("[data-model-state-label]");
if (label) {
label.textContent = state === "loaded" ? list.dataset.labelLoaded
: state === "loading" ? list.dataset.labelLoading : "";
}
});
if (loading && !menu.hidden) {
menu._stateTimer = setTimeout(function () {
if (!menu.hidden) refreshStates(menu);
}, 2000);
}
}).catch(function () { /* No state is a menu without dots, not an error. */ });
} }
function applyFilter(menu, needle) { function applyFilter(menu, needle) {
@@ -1128,7 +1172,19 @@ document.addEventListener("lembas:notify", function (event) {
shrinks the document and scrollTop is clamped to the new maximum, which shrinks the document and scrollTop is clamped to the new maximum, which
for a short panel is somewhere below everything. */ for a short panel is somewhere below everything. */
var outer = scroller(bar); 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;
}); });
})(); })();
@@ -50,6 +50,13 @@
{{ icon("sliders", "icon--sm") }} {{ icon("sliders", "icon--sm") }}
<span class="nav-item__label">{{ t("Models") }}</span> <span class="nav-item__label">{{ t("Models") }}</span>
</a> </a>
{# Its own entry rather than a card on Agents, where it started. Sitting
there made it read as an agent-chat feature -- which is what the owner
took it for, reasonably, since that is what the page is called. #}
<a class="nav-item {{ 'is-active' if section == 'crowd' }}" href="/admin/crowd">
{{ icon("users", "icon--sm") }}
<span class="nav-item__label">{{ t("A crowd") }}</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'audio' }}" href="/admin/audio"> <a class="nav-item {{ 'is-active' if section == 'audio' }}" href="/admin/audio">
{{ icon("speaker", "icon--sm") }} {{ icon("speaker", "icon--sm") }}
<span class="nav-item__label">{{ t("Audio") }}</span> <span class="nav-item__label">{{ t("Audio") }}</span>
@@ -395,76 +395,6 @@
button was pressed, which is what keeps each group's save handler writing one button was pressed, which is what keeps each group's save handler writing one
key. key.
#} #}
{# A third settings group on this page, saved by its own form -- the reason the
Helpers card gives. A crowd is not an agent-chat feature either, but this is the
page somebody opens to find out what one turn may set going. #}
<form method="post" action="/admin/agents/crowd" class="form-grid">
<section class="card">
<h2 class="card__title">{{ t("A crowd") }}</h2>
<p class="field__hint">
A chat can have more than one model in it. The chat's own model answers, then
each of the others in turn; then the order runs <strong>{{ t("backwards") }}</strong>,
each one asked whether it disagrees with anything; and it ends back at the
first, which either closes or sends them round again.
</p>
<div class="alert">
{{ icon("warning", "icon--sm") }}
<span>
One turn costs <strong>{{ t("models × rounds × 2 − 1") }}</strong> replies — four
models over two rounds is fifteen — and on a single local endpoint every
change of speaker also loads a different model. Larger crowds of smaller
models, and sometimes of bigger ones, start going round in circles: that is
what the round limit is for, and it is a limit ordinary work will reach
rather than a runaway backstop.
</span>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true"
{{ 'checked' if crowd.enabled }}>
<span>{{ t("Let a chat have a crowd") }}</span>
</label>
<p class="field__hint">{{ t("Off by default. With it on, each chat's settings panel offers the other models; a chat with none ticked behaves exactly as it always has.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_max_models">{{ t("Most models besides the chat's own") }}</label>
<input class="input" id="crowd_max_models" name="max_models"
type="number" min="1" max="8" step="1" value="{{ crowd.max_models }}">
<p class="field__hint">{{ t("Four is already eight replies a turn at one round each. More voices past that tend to repeat each other rather than add anything.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_max_rounds">{{ t("Most rounds") }}</label>
<input class="input" id="crowd_max_rounds" name="max_rounds"
type="number" min="1" max="5" step="1" value="{{ crowd.max_rounds }}">
<p class="field__hint">{{ t("A round is out and back. Two gives the first model one chance to change its mind after hearing the objections, which is the point of the whole thing; three is where going in circles starts.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_wall_seconds">{{ t("Longest a turn may take") }}</label>
<input class="input" id="crowd_wall_seconds" name="wall_seconds"
type="number" min="60" max="7200" step="30" value="{{ crowd.wall_seconds }}">
<p class="field__hint">{{ t("Across every speaker, not each. A member whose endpoint has stalled cannot then hold the round open all afternoon.") }}</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="collapse_agreement" value="true"
{{ 'checked' if crowd.collapse_agreement }}>
<span>{{ t('Fold away a short "I agree" on the way back') }}</span>
</label>
<p class="field__hint">{{ t("The disagreements are what a crowd is for; a column of bubbles saying nothing is what makes somebody switch it off. The text is still there behind a disclosure.") }}</p>
</div>
<div class="btn-row">
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
</div>
</section>
</form>
<form method="post" action="/admin/agents/subagents" class="form-grid"> <form method="post" action="/admin/agents/subagents" class="form-grid">
<section class="card"> <section class="card">
<h2 class="card__title">{{ t("Helpers") }}</h2> <h2 class="card__title">{{ t("Helpers") }}</h2>
+87
View File
@@ -0,0 +1,87 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "crowd" %}
{% block title %}A crowd - {{ brand.name }}{% endblock %}
{% block heading %}A crowd{% endblock %}
{% block admin_content %}
{#
Its own page rather than a card on Agents, which is where it shipped in 1.6.0.
Sitting there made it read as an agent-chat feature -- the owner took it for one,
reasonably, because that is what the page is called -- and a crowd has nothing to
do with agent chats: it works in any conversation.
#}
<p class="admin-lede">{{ t("Several models answering one turn, in any chat. Not an agent-chat feature: it works in an ordinary conversation, and the control is in the composer beside the tool switches.") }}</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Settings saved.") }}</span></div>
{% endif %}
<form method="post" action="/admin/crowd" class="form-grid">
<section class="card">
<p class="field__hint">
A chat can have more than one model in it. The chat's own model answers, then
each of the others in turn; then the order runs <strong>{{ t("backwards") }}</strong>,
each one asked whether it disagrees with anything; and it ends back at the
first, which either closes or sends them round again.
</p>
<div class="alert">
{{ icon("warning", "icon--sm") }}
<span>
One turn costs <strong>{{ t("models × rounds × 2 − 1") }}</strong> replies — four
models over two rounds is fifteen — and on a single local endpoint every
change of speaker also loads a different model. Larger crowds of smaller
models, and sometimes of bigger ones, start going round in circles: that is
what the round limit is for, and it is a limit ordinary work will reach
rather than a runaway backstop.
</span>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true"
{{ 'checked' if crowd.enabled }}>
<span>{{ t("Let a chat have a crowd") }}</span>
</label>
<p class="field__hint">{{ t("Off by default. With it on, every chat's composer offers the other models; a chat with none ticked behaves exactly as it always has.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_max_models">{{ t("Most models besides the chat's own") }}</label>
<input class="input" id="crowd_max_models" name="max_models"
type="number" min="1" max="8" step="1" value="{{ crowd.max_models }}">
<p class="field__hint">{{ t("Four is already eight replies a turn at one round each. More voices past that tend to repeat each other rather than add anything.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_max_rounds">{{ t("Most rounds") }}</label>
<input class="input" id="crowd_max_rounds" name="max_rounds"
type="number" min="1" max="5" step="1" value="{{ crowd.max_rounds }}">
<p class="field__hint">{{ t("A round is out and back. Two gives the first model one chance to change its mind after hearing the objections, which is the point of the whole thing; three is where going in circles starts.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_wall_seconds">{{ t("Longest a turn may take") }}</label>
<input class="input" id="crowd_wall_seconds" name="wall_seconds"
type="number" min="60" max="7200" step="30" value="{{ crowd.wall_seconds }}">
<p class="field__hint">{{ t("Across every speaker, not each. A member whose endpoint has stalled cannot then hold the round open all afternoon.") }}</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="collapse_agreement" value="true"
{{ 'checked' if crowd.collapse_agreement }}>
<span>{{ t('Fold away a short "I agree" on the way back') }}</span>
</label>
<p class="field__hint">{{ t("The disagreements are what a crowd is for; a column of bubbles saying nothing is what makes somebody switch it off. The text is still there behind a disclosure.") }}</p>
</div>
<div class="btn-row">
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
</div>
</section>
</form>
{% endblock %}
+3
View File
@@ -152,6 +152,9 @@
what `app.js` turns into a sentence on the settings page. what `app.js` turns into a sentence on the settings page.
#} #}
<script> <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" }; window.lembasWorker = { state: "unsupported" };
if (!window.isSecureContext) { if (!window.isSecureContext) {
/* Reported separately from an outright failure: the fix is different. */ /* Reported separately from an outright failure: the fix is different. */
@@ -293,6 +293,97 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{#
Who else answers.
Beside the tool switches rather than buried in Chat settings, and
*inside this form* rather than in the topbar, for one reason each.
The first: somebody deciding who answers is making the same kind of
choice as somebody picking the model, and the first version of this
put it only in the Chat settings panel — behind the ⋯ menu, inside a
chat that already existed. The owner enabled the feature, went
looking, and could not find it. A control nobody can find is a
feature nobody has.
The second: on the new-chat screen there is no chat row to attach
anybody to, so the choice has to *ride along with the first message*
— which means being a field of this form. That is the same mechanism
the scope switches above use, with the same hidden-input trick,
because a browser submits only the ticked boxes and `start_chat`
needs to know which ones were not.
#}
{% if crowd_available %}
<div class="picker picker--up" data-picker>
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
aria-haspopup="menu" aria-expanded="false"
aria-label="{{ t('Crowd') }}" title="{{ t('Crowd') }}">
{{ icon("users") }}
{% if crowd_member_ids %}
<span class="composer__count">{{ crowd_member_ids|length + 1 }}</span>
{% endif %}
</button>
<div class="picker__menu picker__menu--scope" data-picker-menu role="menu"
hidden aria-label="{{ t('Crowd') }}">
<p class="picker__lede">
{{ t("Tick a model to have it answer after this one, then be asked whether it disagrees.") }}
</p>
<p class="picker__group">{{ t("Also answering") }}</p>
{% if chat %}
{# One hidden field for the whole list, always submitted, so
unticking the last box still says something -- an absent checkbox
carries no signal of its own. #}
<input type="hidden" name="crowd_model_ids" value="" form="crowd-form">
{% else %}
<input type="hidden" name="crowd_model_ids" value="">
{% endif %}
{% for model in crowd_available %}
<label class="picker__option picker__option--toggle">
{% if chat %}
{# An existing chat: written at once. The verb is on the checkbox
and not on `#crowd-form`, because htmx binds a trigger to the
annotated element and `change` bubbles through *ancestors* --
which a sibling form is not. `form=` scopes the values, and
only the values: without it the PATCH would carry the
composer's own `content` and `project_dir`, and `update_chat`
answers that with a 409. The same reasoning the agent mode
select below carries. #}
<input type="checkbox" name="crowd_model_ids" value="{{ model.model_id }}"
{{ 'checked' if model.model_id in crowd_member_ids }}
form="crowd-form"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
{% else %}
<input type="checkbox" name="crowd_model_ids" value="{{ model.model_id }}"
{{ 'checked' if model.model_id in crowd_member_ids }}>
{% endif %}
<span class="picker__option-body">
<span class="picker__option-name">{{ model.label }}</span>
{% if model.description %}
<span class="picker__option-note">{{ model.description }}</span>
{% endif %}
</span>
</label>
{% endfor %}
{% if crowd_member_ids %}
{# One sentence and not three, with the numbers as placeholders: a
translation puts the parts in its own order, and two of these
fragments are not sentences in any language. #}
<p class="picker__lede">
{{ t("%(models)s models answer each turn, over up to %(rounds)s rounds.",
models=crowd_member_ids|length + 1, rounds=crowd_rounds) }}
</p>
{% endif %}
{% if crowd_skipped %}
<p class="picker__lede">
{{ t("Skipped, because you cannot reach them any more:") }}
<s>{{ crowd_skipped|join(", ") }}</s>
</p>
{% endif %}
</div>
</div>
{% endif %}
</div> </div>
{# {#
@@ -502,6 +593,12 @@
hx-patch and not hx-post: there is no POST for a chat, only PATCH, and hx-patch and not hx-post: there is no POST for a chat, only PATCH, and
htmx shows nothing when a request 405s -- which is how these controls htmx shows nothing when a request 405s -- which is how these controls
spent the first half of their lives doing nothing. #} spent the first half of their lives doing nothing. #}
{% if chat and crowd_available %}
{# Empty, and a sibling of the composer's form rather than inside it. See the
crowd checkboxes above, and `#agent-mode-form` below, for why both halves
of that sentence matter. #}
<form id="crowd-form"></form>
{% endif %}
{% if chat and chat.kind == "agent" %} {% if chat and chat.kind == "agent" %}
<form id="agent-mode-form"></form> <form id="agent-mode-form"></form>
{% endif %} {% endif %}
+7 -7
View File
@@ -88,24 +88,24 @@
replies: a round produces more bubbles than it has models in it. #} replies: a round produces more bubbles than it has models in it. #}
<span class="badge"> <span class="badge">
{% if crowd.get("phase") == "out" %} {% if crowd.get("phase") == "out" %}
{{ crowd.get("index", 0) + 1 }} of {{ crowd.get("of", 1) }} {{ t("%(n)s of %(total)s", n=crowd.get("index", 0) + 1, total=crowd.get("of", 1)) }}
{% elif crowd.get("phase") == "back" %} {% elif crowd.get("phase") == "back" %}
on the way back {{ t("on the way back") }}
{% else %} {% else %}
closing {{ t("closing") }}
{% endif %} {% endif %}
{% if crowd.get("round", 1) > 1 %} · round {{ crowd.get("round") }}{% endif %} {% if crowd.get("round", 1) > 1 %} · {{ t("round %(n)s", n=crowd.get("round")) }}{% endif %}
</span> </span>
{% if crowd.get("stopped") %} {% if crowd.get("stopped") %}
{# Why a round ended, where it ended. Without this a crowd that ran out of {# Why a round ended, where it ended. Without this a crowd that ran out of
rounds or time simply stops, which reads as the feature failing. #} rounds or time simply stops, which reads as the feature failing. #}
<span class="badge badge--warning" title="{{ t('The round ended here') }}"> <span class="badge badge--warning" title="{{ t('The round ended here') }}">
{% if crowd.get("stopped") == "rounds" %} {% if crowd.get("stopped") == "rounds" %}
no rounds left {{ t("no rounds left") }}
{% elif crowd.get("stopped") == "time" %} {% elif crowd.get("stopped") == "time" %}
out of time {{ t("out of time") }}
{% else %} {% else %}
two endpoints failed {{ t("two endpoints failed") }}
{% endif %} {% endif %}
</span> </span>
{% endif %} {% endif %}
@@ -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,31 +38,44 @@
</div> </div>
{% endif %} {% endif %}
<div class="picker__list"> <div class="picker__list picker__list--models"
data-label-loaded="{{ t('Loaded') }}" data-label-loading="{{ t('Loading') }}">
{% 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"
aria-selected="{{ 'true' if current_model and model.model_id == current_model.model_id else 'false' }}" aria-selected="{{ 'true' if current_model and model.model_id == current_model.model_id else 'false' }}"
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 }}"
data-model-id="{{ model.model_id }}">
{#
The avatar in a slot of its own size, carrying the load-state dot on
its corner. A dot there takes no track, so the columns the context
sizes and the eyes line up on are the ones they had. The state is
fetched when the menu opens (ui.js, /api/models/state) rather than
rendered here: it changes by the minute, and asking every endpoint on
every page render would put a network call in front of each page.
#}
<span class="model-slot" data-model-state="">
{{ model_avatar(model, cls="picker__avatar") }} {{ model_avatar(model, cls="picker__avatar") }}
<span class="picker__option-body"> <span class="model-state" aria-hidden="true"></span>
<span class="visually-hidden" data-model-state-label></span>
</span>
<span class="picker__option-name"> <span class="picker__option-name">
{{ model.label }} <span class="truncate">{{ model.label }}</span>
{% if model.pinned %}{{ icon("pin", "icon--sm picker__pin") }}{% endif %} {% if model.pinned %}{{ icon("pin", "icon--sm picker__pin") }}{% endif %}
</span> </span>
{% if model.description %} <span class="model-ctx mono"
<span class="picker__option-desc">{{ model.description }}</span> {% 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 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 class="model-vision">
{%- if (model.capabilities_json or {}).get("vision") -%}
{{ icon("eye", "icon--sm") }}<span class="visually-hidden">{{ t("Sees images") }}</span>
{%- endif -%}
</span> </span>
{% if current_model and model.model_id == current_model.model_id %} {# Always rendered and shown by `.is-selected`, so it follows ui.js's
{{ icon("check", "icon--sm picker__tick") }} in-place choice rather than staying on the model the page loaded with. #}
{% endif %} <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">
+25 -13
View File
@@ -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") }}
<div style="min-width: 0"> <strong class="model-list__name">
<strong>{{ model.label }}</strong> <span>{{ model.label }}</span>
{% 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 %}
+20
View File
@@ -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
+13
View File
@@ -448,3 +448,16 @@ def test_only_an_administrator_may_customise(db, client, registered):
for path in ("identity", "flavour", "css", "themes"): for path in ("identity", "flavour", "css", "themes"):
assert client.post(f"/admin/customization/{path}", data={}).status_code == 403 assert client.post(f"/admin/customization/{path}", data={}).status_code == 403
def test_the_doors_of_durin_are_a_riddle_not_a_greeting():
""""Speak, friend, and enter" invites a friend to speak. The inscription is a
riddle whose answer is to say the word *friend*, so the shipped line has no
commas. The owner caught it, and the commas must not come back in a
tidy-up."""
from lembas.services.branding import FLAVOUR
for key in ("chat_empty", "error_403"):
line = FLAVOUR[key][2]
assert line.startswith("Speak friend and enter.")
assert "Speak, friend" not in line
+67
View File
@@ -177,6 +177,73 @@ def test_the_round_is_recorded_on_every_row(db, started):
assert len(anchors) == 1 assert len(anchors) == 1
def test_the_reply_that_opened_the_round_is_stamped_too(db, started):
"""The opening bubble says `1 of 3` like every other one.
It is the one contribution the crowd does not start -- the composer does --
so until the round begins there is nothing to stamp it with. Before this, a
two-model round rendered as an unmarked reply followed by one saying `2 of 2`,
with no 1 anywhere.
"""
chat = _crowd_chat(db)
opening = _opening_reply(db, chat)
assert crowd_service.state_of(opening) is None, "nothing to say before it finishes"
assert _advance(db, chat, opening)
db.expire_all()
state = crowd_service.state_of(opening)
assert state is not None
assert (state.phase, state.index) == (crowd_service.PHASE_OUT, 0)
assert state.of == 3
def test_the_opening_stamp_belongs_to_the_same_round(db, started):
chat = _crowd_chat(db)
opening = _opening_reply(db, chat)
order = []
assert _advance(db, chat, opening)
db.expire_all()
order = _incomplete(db, chat)
opened = crowd_service.state_of(opening)
first = crowd_service.state_of(order[0])
# Same question, same clock -- or the chips group two bubbles of one round
# under two different rounds.
assert opened.turn == first.turn
assert opened.started_at == first.started_at
assert opened.round == first.round == 1
def test_the_opening_stamp_is_not_scheduling_state(db, started):
"""It must read as "no round yet" everywhere that decides what happens next.
Fed to the scheduler it would be a member at index 0, which inherits the old
`started_at` -- so regenerating the opening an hour later would end the round
with "out of time" before anybody spoke -- and it would hand that reply a
member's tools and a member's instruction instead of an ordinary first answer.
"""
chat = _crowd_chat(db)
opening = _opening_reply(db, chat)
assert _advance(db, chat, opening)
db.expire_all()
assert crowd_service.state_of(opening) is not None
assert crowd_service.scheduling_state(opening) is None
assert crowd_service.is_opening(crowd_service.state_of(opening))
assert generation_service._opens_the_turn(opening)
def test_a_later_speaker_is_not_mistaken_for_the_opening(db, started):
chat = _crowd_chat(db)
order = _run_round(db, chat, started)
for message in order:
state = crowd_service.state_of(message)
assert not crowd_service.is_opening(state)
# `==` and not `is`: `state_of` builds a fresh Turn on every call.
assert crowd_service.scheduling_state(message) == state
def test_each_speaker_carries_its_own_connection(db, started): def test_each_speaker_carries_its_own_connection(db, started):
"""So `speaker_for` resolves the pair rather than guessing at the id.""" """So `speaker_for` resolves the pair rather than guessing at the id."""
chat = _crowd_chat(db) chat = _crowd_chat(db)
+106
View File
@@ -70,6 +70,87 @@ def _members(db, chat) -> list[str]:
] ]
# --- Reachable where somebody would look --------------------------------------
#
# The feature shipped in 1.6.0 switched on and unreachable: the only control was
# inside the Chat settings panel, behind the ⋯ menu, in a chat that already
# existed. The owner enabled it, went looking, and reported that there was nothing
# to find. A control nobody can find is a feature nobody has, so these assert the
# two places it has to be rather than the one place it was.
def test_the_composer_offers_the_crowd_in_a_chat(client, db):
"""Beside the tool switches, where the comparable decisions are."""
chat = _chat(db)
page = client.get(f"/chat/{chat.id}").text
assert 'name="crowd_model_ids"' in page
assert 'form="crowd-form"' in page
assert '<form id="crowd-form">' in page
def test_the_composer_offers_the_crowd_before_the_chat_exists(client, db):
"""On the new-chat screen there is no row to attach anybody to, so the choice
rides along with the first message — the mechanism the scope switches use."""
page = client.get("/chat").text
assert 'name="crowd_model_ids"' in page
# Riding along, so no sibling form and no PATCH: the composer's own POST
# carries it.
assert '<form id="crowd-form">' not in page
assert 'value="second-model"' in page
def test_starting_a_chat_with_a_crowd_keeps_it(client, db):
"""The end of that path: the first message creates the chat *and* its crowd."""
from sqlalchemy import select as sa_select
client.post(
"/api/chats/start",
data={
"content": "Who is right?",
"model_id": "main-model",
"crowd_model_ids": ["", "second-model", "third-model"],
},
follow_redirects=False,
)
db.expire_all()
chat = db.scalars(sa_select(Chat).order_by(Chat.created_at.desc())).first()
assert [row.model_id for row in sorted(chat.crowd, key=lambda r: r.position)] == [
"second-model",
"third-model",
]
def test_starting_a_chat_refuses_a_model_the_person_cannot_reach(client, db):
"""The same rule as the panel, in the same one function, so there is nowhere
for the two to disagree."""
from sqlalchemy import select as sa_select
group = Group(name="Wheel")
db.add(group)
restricted = db.scalar(select(Model).where(Model.model_id == "third-model"))
restricted.public = False
restricted.groups = [group]
user = _user(db)
user.role = "user"
db.commit()
client.post(
"/api/chats/start",
data={
"content": "Who is right?",
"model_id": "main-model",
"crowd_model_ids": ["second-model", "third-model"],
},
follow_redirects=False,
)
db.expire_all()
chat = db.scalars(sa_select(Chat).order_by(Chat.created_at.desc())).first()
assert [row.model_id for row in chat.crowd] == ["second-model"]
def test_the_composer_control_is_absent_while_the_feature_is_off(client, db):
settings_store.update(db, {"enabled": False}, key=settings_store.CROWD)
assert 'name="crowd_model_ids"' not in client.get("/chat").text
# --- Choosing ----------------------------------------------------------------- # --- Choosing -----------------------------------------------------------------
def test_the_panel_offers_the_other_models(client, db): def test_the_panel_offers_the_other_models(client, db):
chat = _chat(db) chat = _chat(db)
@@ -200,6 +281,31 @@ def test_a_bubble_on_the_way_out_says_which_speaker_it_is(db):
assert "2 of 3" in html assert "2 of 3" in html
def test_the_bubble_that_opened_the_round_says_it_is_first(db):
"""The opening reply is stamped once the round begins, so it says `1 of 3`.
Before that it was the one contribution with no chip at all, which made a
two-model round read as an ordinary answer followed by one labelled `2 of 2`.
"""
chat = _chat(db)
html = _bubble(db, chat, phase=crowd_service.PHASE_OUT, index=0, of=3)
assert "1 of 3" in html
def test_the_chip_is_translated(db):
"""It is prose a person reads, and it was English on a Slovak instance."""
from lembas.web import i18n
chat = _chat(db)
i18n.activate("sk")
try:
html = _bubble(db, chat, phase=crowd_service.PHASE_BACK, index=1, of=3)
finally:
i18n.activate("en")
assert "na ceste späť" in html
assert "on the way back" not in html
def test_a_bubble_on_the_way_back_says_so_and_is_quieter(db): def test_a_bubble_on_the_way_back_says_so_and_is_quieter(db):
chat = _chat(db) chat = _chat(db)
html = _bubble(db, chat, phase=crowd_service.PHASE_BACK) html = _bubble(db, chat, phase=crowd_service.PHASE_BACK)
+22
View File
@@ -582,3 +582,25 @@ def test_an_endpoint_with_no_props_leaves_the_list_alone(client, db, registered,
db.expire_all() db.expire_all()
assert db.get(Model, model.id).reasoning_efforts == ["low", "high"] assert db.get(Model, model.id).reasoning_efforts == ["low", "high"]
def test_a_new_chat_offers_the_models_own_efforts(client: TestClient, db, registered):
"""`/chat?model=` offered the generic three whatever the model took.
On Bonsai (low, medium, xhigh, default xhigh) that drew `high`, which it
rejects, and no `xhigh`, so the configured default was not an option and
the picker fell through to "off". The chat created from that screen got
`xhigh` anyway, so the control said one thing and the first reply did
another. Reported from the live instance.
"""
model = _model(db)
model.model_id = "bonsai"
model.reasoning_efforts = ["low", "medium", "xhigh"]
model.params_json = {"reasoning_effort": "xhigh"}
db.commit()
html = client.get("/chat?model=bonsai").text.replace("\n", "").replace(" ", "")
assert '<option value="xhigh" selected>' in html
assert '<option value="high"' not in html
assert '<option value="off" selected' not in html
+16
View File
@@ -208,3 +208,19 @@ def test_the_desktop_minimum_is_still_declared():
for token in ("--terminal-width-min", "--canvas-width-min"): for token in ("--terminal-width-min", "--canvas-width-min"):
assert f"{token}:" in TOKENS assert f"{token}:" in TOKENS
assert f"var({token})" in APP_CSS 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"
+132
View File
@@ -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"
)
+152
View File
@@ -0,0 +1,152 @@
"""The load-state dot in the model menu: only what an endpoint states.
llama-swap reports `"status": {"value": "loaded" | "unloaded"}` on every entry
of `GET /v1/models`, verified against the live one on 2026-09-28. A hosted API
such as DeepSeek has no such field, so its models must get no state at all --
not "unloaded", which would be a claim nobody made.
"""
from __future__ import annotations
import asyncio
import pytest
from fastapi.testclient import TestClient
from lembas.db.models import Connection, Model
from lembas.services import model_state
LLAMA_SWAP = [
{"id": "bonsai", "status": {"value": "loaded"}},
{"id": "gpt-oss", "status": {"value": "unloaded"}},
{"id": "qwen36", "status": {"value": "starting"}},
]
HOSTED = [{"id": "deepseek-flash", "object": "model"}]
@pytest.fixture(autouse=True)
def _fresh_cache():
model_state.forget()
yield
model_state.forget()
@pytest.mark.parametrize(
("entry", "state"),
[
({"status": {"value": "loaded"}}, "loaded"),
({"status": {"value": "ready"}}, "loaded"),
({"status": "loaded"}, "loaded"),
({"status": {"value": "starting"}}, "loading"),
({"status": {"value": "unloaded"}}, "unloaded"),
({"status": {"value": "stopped"}}, "unloaded"),
({}, ""),
({"status": {}}, ""),
({"status": 3}, ""),
],
)
def test_state_is_read_from_the_entry_or_not_at_all(entry, state):
assert model_state.state_of({"id": "x", **entry}) == state
def _two_connections(db):
local = Connection(name="llama", base_url="http://llama.test/v1", api_key_encrypted="")
hosted = Connection(name="deepseek", base_url="http://hosted.test/v1", api_key_encrypted="")
db.add_all([local, hosted])
db.commit()
served = ((local, ("bonsai", "gpt-oss", "qwen36")), (hosted, ("deepseek-flash",)))
for connection, ids in served:
for model_id in ids:
db.add(Model(connection_id=connection.id, model_id=model_id))
db.commit()
return local, hosted
def _fake_endpoints(monkeypatch, calls):
async def fake(endpoint):
calls.append(endpoint.base_url)
return LLAMA_SWAP if "llama" in endpoint.base_url else HOSTED
monkeypatch.setattr(model_state, "list_models", fake)
def test_only_models_whose_endpoint_states_one_get_a_state(
client: TestClient, db, registered, monkeypatch
):
_two_connections(db)
calls: list[str] = []
_fake_endpoints(monkeypatch, calls)
states = client.get("/api/models/state").json()["states"]
assert states == {"bonsai": "loaded", "gpt-oss": "unloaded", "qwen36": "loading"}
assert "deepseek-flash" not in states
# One request per connection, not per model.
assert sorted(calls) == ["http://hosted.test/v1", "http://llama.test/v1"]
def test_a_silent_endpoint_is_not_asked_again_on_every_open(
client: TestClient, db, registered, monkeypatch
):
"""A hosted API answers with no state every time. Asking it on each click
only to hear nothing again is a request to a third party for no reason."""
_two_connections(db)
calls: list[str] = []
_fake_endpoints(monkeypatch, calls)
client.get("/api/models/state")
client.get("/api/models/state")
assert calls.count("http://hosted.test/v1") == 1
def test_an_unreachable_endpoint_is_a_menu_without_dots(
client: TestClient, db, registered, monkeypatch
):
_two_connections(db)
async def broken(endpoint):
raise OSError("connection refused")
monkeypatch.setattr(model_state, "list_models", broken)
response = client.get("/api/models/state")
assert response.status_code == 200
assert response.json() == {"states": {}}
def test_a_slow_endpoint_cannot_hold_the_menu(db, monkeypatch):
connection = Connection(name="slow", base_url="http://slow.test/v1", api_key_encrypted="")
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="m"))
db.commit()
async def slow(endpoint):
await asyncio.sleep(10)
return LLAMA_SWAP
monkeypatch.setattr(model_state, "list_models", slow)
monkeypatch.setattr(model_state, "TIMEOUT", 0.05)
models = db.query(Model).all()
assert asyncio.run(model_state.states_for(models)) == {}
def test_the_menu_has_a_slot_for_every_model(client: TestClient, db, registered):
"""Every option emits the slot, whatever its endpoint says: the dot is
placed by ui.js after the menu opens, so a model with no slot could never
show one."""
_two_connections(db)
html = client.get("/chat").text
for model_id in ("bonsai", "gpt-oss", "qwen36", "deepseek-flash"):
start = html.index(f'data-model-id="{model_id}"')
option = html[start : html.index("</button>", start)]
assert 'data-model-state=""' in option
assert "data-label-loaded=" in html
def test_the_state_needs_a_signed_in_reader(client: TestClient):
response = client.get("/api/models/state", follow_redirects=False)
assert response.status_code in (401, 303, 307)
+85
View File
@@ -0,0 +1,85 @@
"""A grid that reflows can still overflow a phone, and twice it has.
`repeat(auto-fit, minmax(13rem, 1fr))` puts two 208px cards side by side on a
390px screen: `auto-fit` decides how many columns fit, and a track whose minimum
is a fixed length never gives that minimum up. The tree's standing rule is to
write `minmax(min(100%, 13rem), 1fr)` instead, so the *track* yields rather than
the viewport.
Half of the rule is not the rule. `.suggestions` had `width: 100%` and got the
`min(100%, …)` track, and still rendered 455px wide inside a 366px column -- it
is a grid item, so it carries `min-width: auto`, which for a grid item means a
min-content floor, and a floor beats `width`. Worse, the floor is measured while
the percentage is indefinite, so the track falls back to a card's max-content:
adding `min(100%, …)` on its own moved the overflow from 428px to 455px.
So both halves are asserted here, on every auto-fit grid in the stylesheets. A
stylesheet cannot see whether a given grid is a flex item on some page today or
becomes one next week, and `min-width: 0` costs nothing where it is not needed.
Found on a phone, on the one screen the screenshot harness had never actually
rendered -- `scripts/shoot.py` builds its client without a lifespan, so the
suggestion cards were absent from every shot ever taken of the new-chat screen.
That is fixed there; this file is the cheap half that runs in the suite.
"""
from __future__ import annotations
import re
from pathlib import Path
import lembas
CSS = Path(lembas.__file__).parent / "web/static/css"
# `[^{}]*` cannot cross a brace, so an `@media` prelude never matches and the
# rules nested inside it do.
RULE = re.compile(r"([^{}]*)\{([^{}]*)\}")
COMMENT = re.compile(r"/\*.*?\*/", re.S)
def _auto_grids() -> list[tuple[str, str, dict[str, str]]]:
found = []
for path in sorted(CSS.glob("*.css")):
text = COMMENT.sub("", path.read_text(encoding="utf-8"))
for prelude, body in RULE.findall(text):
if "auto-fit" not in body and "auto-fill" not in body:
continue
declarations = {
part.partition(":")[0].strip(): part.partition(":")[2].strip()
for part in body.split(";")
if ":" in part
}
found.append((path.name, prelude.strip(), declarations))
return found
def test_the_stylesheets_still_have_auto_fit_grids_to_check():
# Otherwise the two tests below pass by finding nothing, which is how a
# coverage test quietly stops covering anything.
assert len(_auto_grids()) >= 4
def test_every_auto_fit_track_can_give_up_its_minimum():
offenders = [
f"{name} {selector}"
for name, selector, declarations in _auto_grids()
for value in [declarations.get("grid-template-columns", "")]
if "minmax(" in value and "min(100%" not in value
]
assert not offenders, (
"an auto-fit track with a fixed minimum overflows a phone; write "
f"minmax(min(100%, X), 1fr): {offenders}"
)
def test_every_auto_fit_grid_drops_its_automatic_minimum_size():
offenders = [
f"{name} {selector}"
for name, selector, declarations in _auto_grids()
if declarations.get("min-width") != "0"
]
assert not offenders, (
"a grid item's `min-width: auto` is a min-content floor and beats "
f"`width`, so `min(100%, …)` alone does not save it: {offenders}"
)
+30
View File
@@ -311,3 +311,33 @@ def test_the_worker_precaches_what_a_page_will_ask_for():
source = (STATIC_DIR / "js" / "sw.js").read_text() source = (STATIC_DIR / "js" / "sw.js").read_text()
assert 'path + "?v=" + VERSION' in source assert 'path + "?v=" + VERSION' in source
assert "versioned(path)" 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
+55
View File
@@ -177,6 +177,61 @@ def test_the_tab_reset_finds_the_container_that_actually_scrolls():
assert "scrollHeight > " in SOURCE 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_composer_is_as_wide_as_its_column_not_its_hint():
"""With `max-width` and auto margins alone, the box was as wide as its
widest content inside a flex column, so the vision hint under it decided:
768px for GPT-OSS ("has no vision, so images will not be sent") and 538px
for a model that sees images. Reported from the live instance with four
screenshots."""
chat = (ROOT / "web/static/css/chat.css").read_text(encoding="utf-8")
start = chat.index(".composer__inner {")
rule = chat[start : chat.index("}", start)]
assert "width: 100%" in rule
assert "max-width: var(--thread-max-width)" in rule
def test_the_two_ends_of_the_shell_stay_level(): def test_the_two_ends_of_the_shell_stay_level():
"""The sidebar footer and the composer sit either side of the same vertical """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 edge and are both content-sized, so without a common floor they end at