Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb8a223fa4
|
||
|
|
dfd8418d95
|
||
|
|
db22962164
|
||
|
|
793f9c8cad
|
||
|
|
da43bc1459
|
||
|
|
8503c6c775
|
+152
@@ -16,6 +16,158 @@ 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
|
## 1.8.0
|
||||||
|
|
||||||
- **The crowd is where you would look for it.** In 1.6.0 the only way to add a
|
- **The crowd is where you would look for it.** In 1.6.0 the only way to add a
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||||
|
|
||||||
__version__ = "1.8.0"
|
__version__ = "1.9.0"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)}
|
||||||
@@ -736,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,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from lembas.api import (
|
|||||||
folders,
|
folders,
|
||||||
library,
|
library,
|
||||||
messages,
|
messages,
|
||||||
|
models,
|
||||||
pages,
|
pages,
|
||||||
preferences,
|
preferences,
|
||||||
push,
|
push,
|
||||||
@@ -204,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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 "
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -1085,6 +1089,13 @@ MESSAGES.update(
|
|||||||
"%(models)s models answer each turn, over up to %(rounds)s rounds.": (
|
"%(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."
|
"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ž",
|
||||||
|
|||||||
@@ -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. */
|
||||||
@@ -451,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;
|
||||||
|
|||||||
@@ -452,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,
|
||||||
@@ -460,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;
|
||||||
@@ -1377,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;
|
||||||
@@ -1730,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;
|
||||||
|
|||||||
@@ -1003,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -281,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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user