Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8503c6c775
|
||
|
|
ab32c68a8f
|
@@ -16,6 +16,88 @@ for 1.0.0 have something to be assembled from.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 1.8.1
|
||||
|
||||
Three fixes to how a crowd behaves, found by reading one real round on the live
|
||||
instance rather than by testing: two models, one round, a question that asked for
|
||||
something to be *made*.
|
||||
|
||||
- **A member no longer answers the question again.** Asked to pick a language and
|
||||
write an example, the main model wrote Python; the second model gave a genuinely
|
||||
useful critique of it — and then answered the original question itself, in a
|
||||
different language. Nothing in its instruction said not to. It now says so:
|
||||
*respond to what is above you; do not answer the person's original request again
|
||||
yourself.* A member that produces a rival answer is not a second opinion, it is
|
||||
a second first opinion, and it is what takes a round off the question.
|
||||
|
||||
- **The model that opened the round no longer capitulates.** Told to write the
|
||||
final answer and take what the others got right, it abandoned its own perfectly
|
||||
good answer, wrote *"I agree that Rust is the superior choice"* with no argument
|
||||
anywhere for why, and rewrote everything in the newcomer's language. Both
|
||||
closing instructions now carry: *your own answer is not automatically the worse
|
||||
one for having been written first; change your position where somebody gave you
|
||||
a reason, and say what the reason was.*
|
||||
|
||||
This mattered more than it reads. All three answers were compiled: the original
|
||||
Python was fine, the critic's Rust compiled and ran — and **the merged answer
|
||||
that was actually delivered did not compile at all**. A crowd that ends by
|
||||
agreeing with whoever spoke last can be worse than the model that started it.
|
||||
|
||||
- **The bubble that opens a round now says `1 of 3` like every other one.** It was
|
||||
the single contribution with no chip, because the crowd does not start it — the
|
||||
composer does, and a round only begins when it finishes. So a two-model round
|
||||
read as an ordinary reply followed by one labelled `2 of 2`, with no 1 anywhere.
|
||||
It is stamped when the round begins, and that stamp is deliberately invisible to
|
||||
everything that decides what happens next: fed to the scheduler it would inherit
|
||||
the round's clock, so regenerating the opening an hour later would end the round
|
||||
with "out of time" before anybody spoke.
|
||||
|
||||
- Fixed: **the crowd chip was never translated.** `1 of 3`, `on the way back`,
|
||||
`closing`, `no rounds left` and the rest were English on a Slovak instance.
|
||||
|
||||
**Worth knowing, and not a bug:** with **two** models there is no backward pass at
|
||||
all. The way back would contain only the model that opened the round, whose turn
|
||||
*is* the close — so `crowd.disagree` never fires. You need at least three models
|
||||
before a single "do you disagree" bubble can exist.
|
||||
|
||||
## 1.8.0
|
||||
|
||||
- **The crowd is where you would look for it.** In 1.6.0 the only way to add a
|
||||
model to a chat was the Chat settings panel — behind the ⋯ menu, inside a chat
|
||||
that already existed — and the switch that turns the feature on was a card on
|
||||
the Agents page. Somebody who enabled it went looking and found nothing, which
|
||||
is the correct outcome of that arrangement.
|
||||
|
||||
Now there is a **crowd button in the composer**, beside the attachment and
|
||||
scope buttons, on both the chat screen and Messages. It carries a count when
|
||||
the chat has a crowd, it lists the models you can reach, and it says what the
|
||||
turn will cost before you tick anything. On the new-chat screen the choice
|
||||
**rides along with the first message**, so a chat can start as a crowd rather
|
||||
than having to be converted into one.
|
||||
|
||||
The instance switch and its bounds have moved to their own page, **Admin →
|
||||
Crowd**.
|
||||
|
||||
- Fixed: **the new-chat screen was wider than a phone.** Before the first
|
||||
message, the suggestion cards pushed the conversation 65px past the edge of a
|
||||
390px screen and it could be dragged sideways; after the first message it
|
||||
looked right, because the cards were gone. Reported from a phone.
|
||||
|
||||
Two things were true at once. The cards' grid asked for a minimum column width
|
||||
it could not give up — the ordinary version of this bug — and it was *also* a
|
||||
grid item, which means it carried a min-content floor that beats `width: 100%`
|
||||
outright. Fixing only the first made it 27px worse. Both are fixed, on all four
|
||||
grids in the stylesheets that could have it, and a test now refuses either half
|
||||
of the pair on its own.
|
||||
|
||||
The reason this survived four releases of narrow-width checking is worth
|
||||
recording: the screenshot harness built its client without running the
|
||||
application's startup, so the suggestion cards were **absent from every shot
|
||||
ever taken of that screen**, and its overflow check deliberately ignored
|
||||
anything inside a scrolling box — correct for a wide table in its own scroller,
|
||||
blind to a box that scrolls sideways when nobody asked it to. Both are fixed,
|
||||
and the harness now names the offending element and the child responsible.
|
||||
|
||||
## 1.7.0
|
||||
|
||||
- **The interface speaks Slovak.** Pick a language under **Appearance** in your
|
||||
|
||||
+129
-2
@@ -26,6 +26,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
@@ -128,8 +129,65 @@ window.__measure = function () {
|
||||
return found.sort(function (a, b) { return b.over - a.over; }).slice(0, 8);
|
||||
}
|
||||
|
||||
/* --- A box that scrolls sideways when nobody asked it to -----------------
|
||||
|
||||
The blind spot that hid the suggestions bug through forty measurements.
|
||||
`.suggestions` rendered 455px wide inside a 390px `.thread-scroll`, and
|
||||
every check above looked straight past it: `culprits('x')` skips anything
|
||||
with a scrollable ancestor -- correct for a table inside its own scroller,
|
||||
wrong for the scroller itself -- and `scrollsSideways` stayed false because
|
||||
`.thread-scroll` absorbed the overflow instead of the document.
|
||||
|
||||
"Authored" is the distinction that makes this reportable rather than noise.
|
||||
The tree's rule is that anything wide gets its OWN scroller, so a wrapper
|
||||
carrying `overflow-x: auto` in a stylesheet is right. A box given only
|
||||
`overflow-y: auto` scrolls sideways as well, because the other axis then
|
||||
computes to `auto` -- and that is always a bug. Computed style cannot tell
|
||||
those apart, both being `auto`, so the rules that say it are read off the
|
||||
stylesheets -- in Python, by `authored_sideways()` below, and not from the
|
||||
CSSOM here: a stylesheet loaded over `file://` is a foreign origin for
|
||||
`cssRules` even with `--allow-file-access-from-files`, and every sheet
|
||||
throws. That silently found *nothing authored*, which turns this check into
|
||||
"every vertical scroller is a bug" -- so the list arriving empty is a hard
|
||||
error rather than a clean run. */
|
||||
var sidewaysAuthors = __SIDEWAYS_AUTHORS__;
|
||||
|
||||
function authoredSideways(el) {
|
||||
if (el.style.overflowX || el.style.overflow) return true;
|
||||
for (var i = 0; i < sidewaysAuthors.length; i++) {
|
||||
try { if (el.matches(sidewaysAuthors[i])) return true; } catch (e) { /* :has() etc */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var sideways = [];
|
||||
document.querySelectorAll('body, body *').forEach(function (el) {
|
||||
var ox = getComputedStyle(el).overflowX;
|
||||
if (ox !== 'auto' && ox !== 'scroll') return;
|
||||
if (el.scrollWidth <= el.clientWidth + 1) return;
|
||||
if (authoredSideways(el)) return;
|
||||
/* Which child is doing it. "`.thread-scroll` scrolls sideways" is not
|
||||
actionable; "`.suggestions` is 455px inside its 390px" is. */
|
||||
var worst = null;
|
||||
el.querySelectorAll('*').forEach(function (kid) {
|
||||
var over = kid.getBoundingClientRect().width - el.clientWidth;
|
||||
if (over > 1 && (!worst || over > worst.over)) {
|
||||
worst = {tag: kid.tagName.toLowerCase(),
|
||||
cls: (kid.className && kid.className.toString().slice(0, 50)) || '',
|
||||
w: Math.round(kid.getBoundingClientRect().width),
|
||||
over: Math.round(over)};
|
||||
}
|
||||
});
|
||||
sideways.push({tag: el.tagName.toLowerCase(),
|
||||
cls: (el.className && el.className.toString().slice(0, 50)) || '',
|
||||
scrollW: el.scrollWidth, clientW: el.clientWidth,
|
||||
widest: worst});
|
||||
});
|
||||
|
||||
var shell = document.querySelector('.shell');
|
||||
return {
|
||||
sidewaysScrollers: sideways.slice(0, 8),
|
||||
sidewaysCount: sideways.length,
|
||||
docScrollH: de.scrollHeight,
|
||||
innerH: window.innerHeight,
|
||||
docScrollW: de.scrollWidth,
|
||||
@@ -167,6 +225,42 @@ window.__measure = function () {
|
||||
"""
|
||||
|
||||
|
||||
@cache
|
||||
def authored_sideways() -> tuple[str, ...]:
|
||||
"""Selectors whose rules really do ask for horizontal scrolling.
|
||||
|
||||
The tree's rule is that anything wide gets its own scroller, so these are
|
||||
the correct ones: a table wrapper, a code block, the tab bar. Everything
|
||||
else that scrolls sideways is `overflow-y: auto` dragging the other axis
|
||||
along with it, which is always a bug and is what `.suggestions` did.
|
||||
"""
|
||||
selectors: list[str] = []
|
||||
for path in sorted((STATIC / "css").glob("*.css")):
|
||||
text = re.sub(r"/\*.*?\*/", "", path.read_text(), flags=re.S)
|
||||
# Innermost blocks only: `[^{}]*` cannot cross a brace, so an `@media`
|
||||
# prelude never matches and the rules inside it do.
|
||||
for prelude, body in re.findall(r"([^{}]*)\{([^{}]*)\}", text):
|
||||
wants = False
|
||||
for declaration in body.split(";"):
|
||||
name, _, value = declaration.partition(":")
|
||||
name, value = name.strip().lower(), value.strip().lower()
|
||||
if name not in ("overflow", "overflow-x") or not value:
|
||||
continue
|
||||
# `overflow: hidden auto` is x then y, so the first word is ours;
|
||||
# `overflow: auto` is both.
|
||||
wants = wants or value.split()[0] in ("auto", "scroll")
|
||||
if not wants:
|
||||
continue
|
||||
selectors += [
|
||||
part.strip()
|
||||
for part in prelude.split(",")
|
||||
if part.strip() and not part.strip().startswith("@")
|
||||
]
|
||||
if not selectors:
|
||||
raise SystemExit("read no horizontal-overflow rules -- the sideways check would cry wolf")
|
||||
return tuple(selectors)
|
||||
|
||||
|
||||
def build_client():
|
||||
import lembas.config as config_mod
|
||||
|
||||
@@ -198,6 +292,17 @@ def build_client():
|
||||
db.flush()
|
||||
for name in ("gemma4-moe", "qwen3-coder"):
|
||||
db.add(Model(connection_id=connection.id, model_id=name, display_name=name))
|
||||
|
||||
# 🚨 The suggestion cards are seeded by the startup hook, and `TestClient(app)`
|
||||
# runs a lifespan only inside a `with` block -- so every shot of the new-chat
|
||||
# screen ever taken by this script was of a page with its cards missing. That
|
||||
# is how a grid 65px wider than a phone survived forty measurements. Seeded
|
||||
# here rather than by entering the lifespan, which would also start the
|
||||
# schedule ticker and rehydrate background jobs inside a screenshot run.
|
||||
from lembas.services.suggestions import seed_defaults as seed_suggestions
|
||||
|
||||
with session_scope() as db:
|
||||
seed_suggestions(db)
|
||||
return client
|
||||
|
||||
|
||||
@@ -219,6 +324,20 @@ def rewrite(html: str, client, assets: Path) -> str:
|
||||
html,
|
||||
)
|
||||
|
||||
# Anything else the *application* serves rather than mounts. Model avatars live
|
||||
# under `/uploads/models/…`, which is a route behind auth -- so they cannot be
|
||||
# pointed at a file on disk and have to be fetched through the client like
|
||||
# `/branding.css` above. A real instance has them and a fixture does not, which
|
||||
# is exactly the difference that makes a page measured here unlike the page
|
||||
# somebody is looking at.
|
||||
for url in sorted({*re.findall(r'\bsrc="(/(?:uploads|branding)/[^"?]+)"', html)}):
|
||||
response = client.get(url)
|
||||
if response.status_code != 200:
|
||||
continue
|
||||
name = "fetched-" + url.strip("/").replace("/", "-")
|
||||
(assets / name).write_bytes(response.content)
|
||||
html = html.replace(f'src="{url}"', f'src="file://{assets}/{name}"')
|
||||
|
||||
# Fail loudly, and only about things that decide how the page LOOKS: every
|
||||
# `src`, and `href` on a <link>. An `href` on an anchor is a destination,
|
||||
# not an asset -- flagging those makes the guard cry wolf on every page and
|
||||
@@ -249,14 +368,15 @@ def rewrite(html: str, client, assets: Path) -> str:
|
||||
if missing:
|
||||
raise SystemExit(f"REWRITTEN TO NOTHING -- still an unstyled document: {missing[:5]}")
|
||||
|
||||
# The one-time notifications offer is a modal over the very page we came
|
||||
# The one-time notifications offer is a modal over the very page we came
|
||||
# to measure, and it is gated on a localStorage key. Set it in the head, so
|
||||
# it runs before the deferred script that reads it.
|
||||
quiet = (
|
||||
"<script>try{localStorage.setItem('lembas-notifications-asked','1');}"
|
||||
"catch(e){}</script>"
|
||||
)
|
||||
return html.replace("</head>", quiet + MEASURE + "</head>", 1)
|
||||
measure = MEASURE.replace("__SIDEWAYS_AUTHORS__", json.dumps(list(authored_sideways())))
|
||||
return html.replace("</head>", quiet + measure + "</head>", 1)
|
||||
|
||||
|
||||
def shoot(client, path: str, width: int, height: int, theme: str, outdir: Path) -> dict:
|
||||
@@ -391,6 +511,13 @@ def main() -> None:
|
||||
flags.append(f"DOC-SCROLLS({r['docScrollH']}>{r['innerH']})")
|
||||
if r["scrollsSideways"]:
|
||||
flags.append(f"SIDEWAYS({r['docScrollW']}>{r['innerW']})")
|
||||
for s in r.get("sidewaysScrollers", []):
|
||||
widest = s["widest"]
|
||||
blame = f"<{widest['tag']}.{widest['cls']} {widest['w']}px" if widest else ""
|
||||
flags.append(
|
||||
f"SCROLLER-SIDEWAYS({s['tag']}.{s['cls']} "
|
||||
f"{s['scrollW']}>{s['clientW']}{blame})"
|
||||
)
|
||||
if r["overflowCount"]:
|
||||
flags.append(f"overflow:{r['overflowCount']}")
|
||||
if r["smallCount"]:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "1.7.0"
|
||||
__version__ = "1.8.1"
|
||||
|
||||
@@ -66,10 +66,6 @@ async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = F
|
||||
# reply is allowed to set going on its own, and a nav entry for one
|
||||
# card would be worse than the near-miss.
|
||||
"subagents": settings_store.subagents(db),
|
||||
# And a third group on the same page, for the same reason: a crowd is
|
||||
# not an agent-chat feature either, but this is where somebody comes to
|
||||
# find out what one turn is allowed to set going.
|
||||
"crowd": settings_store.crowd(db),
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
@@ -115,35 +111,6 @@ async def save_subagents(
|
||||
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/crowd")
|
||||
async def save_crowd(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
max_models: int = Form(4),
|
||||
max_rounds: int = Form(2),
|
||||
wall_seconds: int = Form(900),
|
||||
collapse_agreement: bool = Form(False),
|
||||
) -> Response:
|
||||
"""Its own route, for the reason `save_subagents` gives above."""
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
# Clamped here as well as on read. Every floor is one: a zero would be
|
||||
# the feature switched off wearing the switch's clothes, and that is a
|
||||
# thing to answer in one place.
|
||||
"max_models": min(max(max_models, 1), 8),
|
||||
"max_rounds": min(max(max_rounds, 1), 5),
|
||||
"wall_seconds": min(max(wall_seconds, 60), 7200),
|
||||
"collapse_agreement": collapse_agreement,
|
||||
},
|
||||
key=settings_store.CROWD,
|
||||
)
|
||||
log.info("crowd %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_agents(
|
||||
db: Db,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""The crowd: several models answering one turn, in any chat.
|
||||
|
||||
Its own module because it is its own page, and it is its own page because as a card
|
||||
on `/admin/agents` it read as an agent-chat feature. It is not one: a crowd works in
|
||||
an ordinary conversation, and the owner reasonably concluded otherwise from where
|
||||
the switch was sitting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.services import settings_store
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/crowd", tags=["admin-crowd"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def crowd_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
"""Its own page, for the reason its template records: as a card on the Agents
|
||||
screen it read as an agent-chat feature, which it is not."""
|
||||
return render(
|
||||
request,
|
||||
"admin/crowd.html",
|
||||
{"crowd": settings_store.crowd(db), "saved": saved},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_crowd(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
max_models: int = Form(4),
|
||||
max_rounds: int = Form(2),
|
||||
wall_seconds: int = Form(900),
|
||||
collapse_agreement: bool = Form(False),
|
||||
) -> Response:
|
||||
"""One group, one form, one route.
|
||||
|
||||
The bounds are clamped here as well as in `settings_store.crowd`, which is the
|
||||
same belt-and-braces `save_subagents` in `admin_agents.py` uses: a value posted
|
||||
past this route -- by an older page, or by hand -- still reads back sane.
|
||||
"""
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
# Every floor is one: a zero would be the feature switched off
|
||||
# wearing the switch's clothes.
|
||||
"max_models": min(max(max_models, 1), 8),
|
||||
"max_rounds": min(max(max_rounds, 1), 5),
|
||||
"wall_seconds": min(max(wall_seconds, 60), 7200),
|
||||
"collapse_agreement": collapse_agreement,
|
||||
},
|
||||
key=settings_store.CROWD,
|
||||
)
|
||||
log.info("crowd %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
return RedirectResponse("/admin/crowd?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
+48
-33
@@ -296,6 +296,11 @@ async def start_chat(
|
||||
scope_on: list[str] = Form(default=[]),
|
||||
scope_skill_all: list[str] = Form(default=[]),
|
||||
scope_skill_on: list[str] = Form(default=[]),
|
||||
# Who else answers, as the crowd menu stood before the first word. There is no
|
||||
# chat row yet to attach members to, so the choice rides along with the message
|
||||
# -- the same mechanism the scope switches above use, and the reason the control
|
||||
# lives inside the composer's form rather than in the topbar.
|
||||
crowd_model_ids: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
"""Create a chat from its first message.
|
||||
|
||||
@@ -329,6 +334,8 @@ async def start_chat(
|
||||
skills_off=frozenset(scope_skill_all) - frozenset(scope_skill_on),
|
||||
)
|
||||
|
||||
_apply_crowd(db, chat, user, crowd_model_ids)
|
||||
|
||||
_adopt_draft(db, user, draft_id, chat)
|
||||
|
||||
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
||||
@@ -1510,6 +1517,45 @@ def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _apply_crowd(db: DBSession, chat: Chat, user: User, values: list[str]) -> None:
|
||||
"""Replace a chat's crowd with the models named, in the order named.
|
||||
|
||||
One implementation for both the composer (where the choice rides along with
|
||||
the first message) and the settings panel, because two would be two places to
|
||||
forget a rule -- and there are three:
|
||||
|
||||
* **Checked against what this person can reach**, never against what exists.
|
||||
A control checked only in the template is advisory, and a crafted request
|
||||
walks past it. Same reasoning as the model branch in `update_chat`.
|
||||
* **Never the chat's own model**, which would answer twice in a row.
|
||||
* **Capped by `crowd.max_models`**, on the way in as well as on the way out.
|
||||
|
||||
The connection is stored beside the id because `Model` is unique on the pair,
|
||||
and a model offered by two connections is two rows with different capabilities.
|
||||
"""
|
||||
from lembas.db.models import CrowdMember
|
||||
|
||||
settings = settings_store.crowd(db)
|
||||
reachable = {
|
||||
model.model_id: model for model in chat_service.available_models(db, user)
|
||||
}
|
||||
wanted: list[str] = []
|
||||
for value in values:
|
||||
value = str(value).strip()
|
||||
if value and value in reachable and value != chat.model_id and value not in wanted:
|
||||
wanted.append(value)
|
||||
wanted = wanted[: int(settings["max_models"])]
|
||||
|
||||
chat.crowd = [
|
||||
CrowdMember(
|
||||
model_id=model_id,
|
||||
connection_id=reachable[model_id].connection_id,
|
||||
position=index,
|
||||
)
|
||||
for index, model_id in enumerate(wanted)
|
||||
]
|
||||
|
||||
|
||||
def _messages_after(db: DBSession, message: Message) -> list[Message]:
|
||||
"""Everything later in this chat than one message.
|
||||
|
||||
@@ -2117,39 +2163,8 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
|
||||
if "crowd_model_ids" in form:
|
||||
# The same shape as the bases above: one field always sent, so clearing
|
||||
# every box clears the crowd. Checked against what this person can reach
|
||||
# rather than against what exists, or the picker is advisory and a crafted
|
||||
# request walks past it -- the reasoning the model branch carries.
|
||||
from lembas.db.models import CrowdMember
|
||||
|
||||
settings = settings_store.crowd(db)
|
||||
reachable = {
|
||||
model.model_id for model in chat_service.available_models(db, user)
|
||||
}
|
||||
wanted: list[str] = []
|
||||
for value in form.getlist("crowd_model_ids"):
|
||||
value = str(value).strip()
|
||||
# Never the chat's own model: it would answer twice in a row, which is
|
||||
# nobody's idea of a second opinion.
|
||||
if value and value in reachable and value != chat.model_id and value not in wanted:
|
||||
wanted.append(value)
|
||||
wanted = wanted[: int(settings["max_models"])]
|
||||
|
||||
chat.crowd = [
|
||||
CrowdMember(
|
||||
model_id=model_id,
|
||||
connection_id=next(
|
||||
(
|
||||
model.connection_id
|
||||
for model in chat_service.available_models(db, user)
|
||||
if model.model_id == model_id
|
||||
),
|
||||
None,
|
||||
),
|
||||
position=index,
|
||||
)
|
||||
for index, model_id in enumerate(wanted)
|
||||
]
|
||||
# every box clears the crowd.
|
||||
_apply_crowd(db, chat, user, form.getlist("crowd_model_ids"))
|
||||
|
||||
submitted_params = {name: form[name] for name in _PARAM_RANGES if name in form}
|
||||
if submitted_params:
|
||||
|
||||
+29
-12
@@ -83,7 +83,9 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
else []
|
||||
),
|
||||
"attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
|
||||
**_crowd_context(db, user, chat, models),
|
||||
**_crowd_context(
|
||||
db, user, chat, models, current.model_id if current is not None else ""
|
||||
),
|
||||
# What *this* model takes, not the three every model used to be assumed
|
||||
# to take. The vocabulary is per model -- gpt-oss has no `xhigh` and
|
||||
# Bonsai has no `high`, and sending the wrong one does not degrade, it
|
||||
@@ -197,12 +199,17 @@ def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _crowd_context(db: DBSession, user: User, chat: Chat | None, models: list) -> dict:
|
||||
def _crowd_context(
|
||||
db: DBSession, user: User, chat: Chat | None, models: list, default_model_id: str = ""
|
||||
) -> dict:
|
||||
"""Who else could answer in this chat, and what that would cost.
|
||||
|
||||
Empty — and the panel then shows nothing rather than an empty control — when
|
||||
the feature is off, when there is nobody else to add, or on the new-chat
|
||||
screen, where there is no chat to attach anybody to yet.
|
||||
Offered on the **new-chat screen as well**, where there is no chat row yet: the
|
||||
choice rides along with the first message, the way the scope switches do. The
|
||||
first version of this was per-chat only and therefore invisible to anybody
|
||||
setting a conversation up — which is how the feature shipped switched on and
|
||||
unreachable. Empty only when the feature is off or there is nobody else to add,
|
||||
and then the control is absent rather than being an empty menu.
|
||||
|
||||
The cost is spelled out because it is the thing somebody will not have thought
|
||||
about: a turn is `speakers x rounds x 2 - 1` replies, and on one local endpoint
|
||||
@@ -211,21 +218,31 @@ def _crowd_context(db: DBSession, user: User, chat: Chat | None, models: list) -
|
||||
from lembas.services import crowd as crowd_service
|
||||
|
||||
settings = settings_store.crowd(db)
|
||||
if chat is None or not settings["enabled"]:
|
||||
if not settings["enabled"]:
|
||||
return {"crowd_available": [], "crowd_member_ids": [], "crowd_skipped": []}
|
||||
|
||||
others = [model for model in models if model.model_id != chat.model_id]
|
||||
members = [
|
||||
row.model_id
|
||||
for row in sorted(chat.crowd, key=lambda row: (row.position, row.model_id))
|
||||
]
|
||||
# On the new-chat screen the "own" model is whichever one the picker is
|
||||
# showing, so the list excludes it for the same reason it does in a chat:
|
||||
# adding it would have it answer twice in a row.
|
||||
own = chat.model_id if chat is not None else default_model_id
|
||||
others = [model for model in models if model.model_id != own]
|
||||
members = (
|
||||
[
|
||||
row.model_id
|
||||
for row in sorted(chat.crowd, key=lambda row: (row.position, row.model_id))
|
||||
]
|
||||
if chat is not None
|
||||
else []
|
||||
)
|
||||
reachable = {model.model_id for model in others}
|
||||
speakers = 1 + len([model_id for model_id in members if model_id in reachable])
|
||||
rounds = int(settings["max_rounds"])
|
||||
return {
|
||||
"crowd_available": others,
|
||||
"crowd_member_ids": [model_id for model_id in members if model_id in reachable],
|
||||
"crowd_skipped": crowd_service.unreachable_members(db, chat, user),
|
||||
"crowd_skipped": (
|
||||
crowd_service.unreachable_members(db, chat, user) if chat is not None else []
|
||||
),
|
||||
# One round is out and back: everybody answers, everybody but the last is
|
||||
# asked whether they disagree, and the main model closes.
|
||||
"crowd_replies": max(1, speakers * 2 - 1),
|
||||
|
||||
@@ -17,6 +17,7 @@ from lembas.api import (
|
||||
admin_agents,
|
||||
admin_audio,
|
||||
admin_branding,
|
||||
admin_crowd,
|
||||
admin_extraction,
|
||||
admin_images,
|
||||
admin_models,
|
||||
@@ -221,6 +222,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(admin_suggestions.router)
|
||||
app.include_router(admin_tools.router)
|
||||
app.include_router(admin_agents.router)
|
||||
app.include_router(admin_crowd.router)
|
||||
app.include_router(push.router)
|
||||
app.include_router(branding.router)
|
||||
|
||||
|
||||
@@ -588,7 +588,10 @@ def build_request(
|
||||
if crowd_turn is None and upto is not None:
|
||||
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
|
||||
# vision. Sending them to one that has not is not a graceful degradation:
|
||||
# most endpoints reject the whole request.
|
||||
|
||||
@@ -134,6 +134,41 @@ def state_of(message: Message | None) -> Turn | 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:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
@@ -374,9 +409,11 @@ __all__ = [
|
||||
"Turn",
|
||||
"elapsed",
|
||||
"is_newest",
|
||||
"is_opening",
|
||||
"member_speakers",
|
||||
"next_turn",
|
||||
"now_stamp",
|
||||
"scheduling_state",
|
||||
"state_of",
|
||||
"tool_defs",
|
||||
"unreachable_members",
|
||||
|
||||
@@ -661,7 +661,10 @@ async def _run(generation: Generation) -> None:
|
||||
# once, here, and used for three decisions: which tools it may have,
|
||||
# which instruction closes its request, and whether it may ask for
|
||||
# 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)
|
||||
may_ask_again = bool(
|
||||
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 = 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.
|
||||
turn_id = state.turn if state is not None else _turn_anchor(db, message)
|
||||
following = crowd_service.next_turn(
|
||||
@@ -2254,6 +2261,22 @@ def _advance_crowd(generation: Generation) -> bool:
|
||||
db.commit()
|
||||
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]
|
||||
placeholder = chat_service.create_message(
|
||||
db,
|
||||
@@ -2281,10 +2304,14 @@ def _opens_the_turn(message: Message) -> bool:
|
||||
"""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
|
||||
opening turn -- which is the one with no crowd state on it at all, because a
|
||||
round begins when that reply *finishes*.
|
||||
opening turn. That reply has no crowd state while it is being written -- a
|
||||
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:
|
||||
|
||||
@@ -2023,17 +2023,28 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
group=GROUP_TASKS,
|
||||
order=451,
|
||||
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 "
|
||||
"been said in different words, which is what makes a crowd feel like an "
|
||||
"echo rather than a second opinion.",
|
||||
"Two failures to word against. One is a member that repeats what has "
|
||||
"already been said in different words, which makes a crowd an echo "
|
||||
"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=(
|
||||
"You are one of several models answering this. The answers above are "
|
||||
"quoted with the name of whoever wrote them; yours comes next.\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 "
|
||||
"have done differently. 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."
|
||||
"have done differently and why. Where you would have made a different "
|
||||
"choice, say what it would buy — naming an alternative is not the same "
|
||||
"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(
|
||||
@@ -2066,11 +2077,23 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"round. Its own fragment rather than a sentence inside the one below, "
|
||||
"because inviting a choice a model cannot express is worse than not "
|
||||
"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=(
|
||||
"You opened this and you are closing it. The others have answered and "
|
||||
"have had the chance to disagree.\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 "
|
||||
"got right, say where you disagree with them and why, and name "
|
||||
"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 "
|
||||
"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 "
|
||||
"looks like one.",
|
||||
"looks like one. It carries the same guard against capitulation as the "
|
||||
"fragment above, and for the same reason.",
|
||||
default=(
|
||||
"You opened this and you are closing it, and this is the last turn: "
|
||||
"there will be no further round.\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 "
|
||||
"got right, say where you disagree with them and why, and attribute "
|
||||
"what you took from whom. Where the disagreement is unresolved, say so "
|
||||
|
||||
@@ -1075,13 +1075,23 @@ MESSAGES.update(
|
||||
"potom nemôže držať kolo otvorené celé poobedie."
|
||||
),
|
||||
"Fold away a short \"I agree\" on the way back": (
|
||||
"Zbaliť krátke „súhlasím“ na cestě späť"
|
||||
"Zbaliť krátke „súhlasím“ na ceste späť"
|
||||
),
|
||||
"Off by default. With it on, each chat's settings panel offers the other models; a chat with none ticked behaves exactly as it always has.": (
|
||||
"Predvolene vypnuté. Po zapnutí panel nastavení každej konverzácie ponúka "
|
||||
"ostatné modely; konverzácia bez zaškrtnutého modelu sa chová presne ako "
|
||||
"vždy."
|
||||
"Off by default. With it on, every chat's composer offers the other models; a chat with none ticked behaves exactly as it always has.": (
|
||||
"Predvolene vypnuté. Po zapnutí ponúka pole na písanie v každej "
|
||||
"konverzácii ostatné modely; konverzácia bez zaškrtnutého modelu sa chová "
|
||||
"presne ako vždy."
|
||||
),
|
||||
"%(models)s models answer each turn, over up to %(rounds)s rounds.": (
|
||||
"Na každý ťah odpovedá %(models)s modelov, a to najviac v %(rounds)s kolách."
|
||||
),
|
||||
"%(n)s of %(total)s": "%(n)s z %(total)s",
|
||||
"on the way back": "na ceste späť",
|
||||
"closing": "uzatvára",
|
||||
"round %(n)s": "kolo %(n)s",
|
||||
"no rounds left": "už žiadne kolá",
|
||||
"out of time": "vypršal čas",
|
||||
"two endpoints failed": "dva endpointy zlyhali",
|
||||
"Check for due work every": "Kontrolovať splatnú prácu každých",
|
||||
"How often it looks": "Ako často sa pozerá",
|
||||
"Nothing may repeat faster than": "Nič sa nesmie opakovať častejšie než",
|
||||
|
||||
@@ -260,7 +260,9 @@ a.tabs__tab { text-decoration: none; }
|
||||
*/
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
/* Both halves of the pair -- see `.grid--2` in app.css. */
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.field-row > .field { margin-bottom: var(--sp-4); }
|
||||
|
||||
@@ -384,8 +384,16 @@ input.visually-hidden[type="checkbox"] {
|
||||
|
||||
/* Multi-column form layout, one definition. */
|
||||
.grid { display: grid; gap: var(--sp-4); }
|
||||
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
|
||||
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); }
|
||||
/* `min(100%, …)` on every auto-fit track and `min-width: 0` with it, for the
|
||||
reason `.suggestions` in chat.css sets out at length. The pair is not
|
||||
optional: `min(100%, …)` stops the track demanding more than the box, and
|
||||
`min-width: 0` stops the *box* demanding more than its parent -- a grid or
|
||||
flex item carries `min-width: auto`, which is a min-content floor, and a
|
||||
floor beats `width`. A stylesheet cannot tell whether one of these grids has
|
||||
been dropped into a flex parent today, so both go on every one of them.
|
||||
`tests/test_narrow_grids.py` refuses a track that has only half the pair. */
|
||||
.grid--2 { min-width: 0; grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); }
|
||||
.grid--3 { min-width: 0; grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr)); }
|
||||
|
||||
/* --- Alerts --------------------------------------------------------------- */
|
||||
.alert {
|
||||
|
||||
@@ -266,7 +266,27 @@
|
||||
*/
|
||||
.suggestions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
/* 🚨 `min-width: 0` is what keeps this grid on the screen, and `width: 100%`
|
||||
alone did not: it is a grid item of `.thread__intro`, so it carries
|
||||
`min-width: auto`, which for a grid item means *a min-content floor* -- and
|
||||
min-width beats width. Its min-content size is two cards side by side, so it
|
||||
rendered 428px wide inside a 390px phone with `width: 100%` set and ignored.
|
||||
|
||||
That floor is also why writing the track as `minmax(min(100%, 13rem), 1fr)`
|
||||
-- the tree's standing rule, and right -- made it *worse* on its own, 428px
|
||||
to 455px: a percentage is indefinite while the floor is being measured, so
|
||||
the track fell back to a card's max-content and raised the very number that
|
||||
was overflowing. The two go together. With the floor removed, `width: 100%`
|
||||
finally resolves against the 366px column, `min(100%, …)` hands the track
|
||||
366px to clamp against, and `auto-fit` places one column.
|
||||
|
||||
It scrolled `.thread-scroll` rather than the page, which is why a pass
|
||||
looking for a document that scrolls sideways never saw it: `overflow-y: auto`
|
||||
makes the other axis scrollable too. Reported on a phone, found by asking
|
||||
which *element* could scroll and then reading its computed `width` against
|
||||
its parent's. */
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
max-width: 40rem;
|
||||
|
||||
@@ -50,6 +50,13 @@
|
||||
{{ icon("sliders", "icon--sm") }}
|
||||
<span class="nav-item__label">{{ t("Models") }}</span>
|
||||
</a>
|
||||
{# Its own entry rather than a card on Agents, where it started. Sitting
|
||||
there made it read as an agent-chat feature -- which is what the owner
|
||||
took it for, reasonably, since that is what the page is called. #}
|
||||
<a class="nav-item {{ 'is-active' if section == 'crowd' }}" href="/admin/crowd">
|
||||
{{ icon("users", "icon--sm") }}
|
||||
<span class="nav-item__label">{{ t("A crowd") }}</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'audio' }}" href="/admin/audio">
|
||||
{{ icon("speaker", "icon--sm") }}
|
||||
<span class="nav-item__label">{{ t("Audio") }}</span>
|
||||
|
||||
@@ -395,76 +395,6 @@
|
||||
button was pressed, which is what keeps each group's save handler writing one
|
||||
key.
|
||||
#}
|
||||
{# A third settings group on this page, saved by its own form -- the reason the
|
||||
Helpers card gives. A crowd is not an agent-chat feature either, but this is the
|
||||
page somebody opens to find out what one turn may set going. #}
|
||||
<form method="post" action="/admin/agents/crowd" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">{{ t("A crowd") }}</h2>
|
||||
<p class="field__hint">
|
||||
A chat can have more than one model in it. The chat's own model answers, then
|
||||
each of the others in turn; then the order runs <strong>{{ t("backwards") }}</strong>,
|
||||
each one asked whether it disagrees with anything; and it ends back at the
|
||||
first, which either closes or sends them round again.
|
||||
</p>
|
||||
|
||||
<div class="alert">
|
||||
{{ icon("warning", "icon--sm") }}
|
||||
<span>
|
||||
One turn costs <strong>{{ t("models × rounds × 2 − 1") }}</strong> replies — four
|
||||
models over two rounds is fifteen — and on a single local endpoint every
|
||||
change of speaker also loads a different model. Larger crowds of smaller
|
||||
models, and sometimes of bigger ones, start going round in circles: that is
|
||||
what the round limit is for, and it is a limit ordinary work will reach
|
||||
rather than a runaway backstop.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true"
|
||||
{{ 'checked' if crowd.enabled }}>
|
||||
<span>{{ t("Let a chat have a crowd") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">{{ t("Off by default. With it on, each chat's settings panel offers the other models; a chat with none ticked behaves exactly as it always has.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="crowd_max_models">{{ t("Most models besides the chat's own") }}</label>
|
||||
<input class="input" id="crowd_max_models" name="max_models"
|
||||
type="number" min="1" max="8" step="1" value="{{ crowd.max_models }}">
|
||||
<p class="field__hint">{{ t("Four is already eight replies a turn at one round each. More voices past that tend to repeat each other rather than add anything.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="crowd_max_rounds">{{ t("Most rounds") }}</label>
|
||||
<input class="input" id="crowd_max_rounds" name="max_rounds"
|
||||
type="number" min="1" max="5" step="1" value="{{ crowd.max_rounds }}">
|
||||
<p class="field__hint">{{ t("A round is out and back. Two gives the first model one chance to change its mind after hearing the objections, which is the point of the whole thing; three is where going in circles starts.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="crowd_wall_seconds">{{ t("Longest a turn may take") }}</label>
|
||||
<input class="input" id="crowd_wall_seconds" name="wall_seconds"
|
||||
type="number" min="60" max="7200" step="30" value="{{ crowd.wall_seconds }}">
|
||||
<p class="field__hint">{{ t("Across every speaker, not each. A member whose endpoint has stalled cannot then hold the round open all afternoon.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="collapse_agreement" value="true"
|
||||
{{ 'checked' if crowd.collapse_agreement }}>
|
||||
<span>{{ t('Fold away a short "I agree" on the way back') }}</span>
|
||||
</label>
|
||||
<p class="field__hint">{{ t("The disagreements are what a crowd is for; a column of bubbles saying nothing is what makes somebody switch it off. The text is still there behind a disclosure.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/admin/agents/subagents" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">{{ t("Helpers") }}</h2>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "crowd" %}
|
||||
|
||||
{% block title %}A crowd - {{ brand.name }}{% endblock %}
|
||||
{% block heading %}A crowd{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
{#
|
||||
Its own page rather than a card on Agents, which is where it shipped in 1.6.0.
|
||||
Sitting there made it read as an agent-chat feature -- the owner took it for one,
|
||||
reasonably, because that is what the page is called -- and a crowd has nothing to
|
||||
do with agent chats: it works in any conversation.
|
||||
#}
|
||||
<p class="admin-lede">{{ t("Several models answering one turn, in any chat. Not an agent-chat feature: it works in an ordinary conversation, and the control is in the composer beside the tool switches.") }}</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Settings saved.") }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/crowd" class="form-grid">
|
||||
<section class="card">
|
||||
<p class="field__hint">
|
||||
A chat can have more than one model in it. The chat's own model answers, then
|
||||
each of the others in turn; then the order runs <strong>{{ t("backwards") }}</strong>,
|
||||
each one asked whether it disagrees with anything; and it ends back at the
|
||||
first, which either closes or sends them round again.
|
||||
</p>
|
||||
|
||||
<div class="alert">
|
||||
{{ icon("warning", "icon--sm") }}
|
||||
<span>
|
||||
One turn costs <strong>{{ t("models × rounds × 2 − 1") }}</strong> replies — four
|
||||
models over two rounds is fifteen — and on a single local endpoint every
|
||||
change of speaker also loads a different model. Larger crowds of smaller
|
||||
models, and sometimes of bigger ones, start going round in circles: that is
|
||||
what the round limit is for, and it is a limit ordinary work will reach
|
||||
rather than a runaway backstop.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true"
|
||||
{{ 'checked' if crowd.enabled }}>
|
||||
<span>{{ t("Let a chat have a crowd") }}</span>
|
||||
</label>
|
||||
<p class="field__hint">{{ t("Off by default. With it on, every chat's composer offers the other models; a chat with none ticked behaves exactly as it always has.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="crowd_max_models">{{ t("Most models besides the chat's own") }}</label>
|
||||
<input class="input" id="crowd_max_models" name="max_models"
|
||||
type="number" min="1" max="8" step="1" value="{{ crowd.max_models }}">
|
||||
<p class="field__hint">{{ t("Four is already eight replies a turn at one round each. More voices past that tend to repeat each other rather than add anything.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="crowd_max_rounds">{{ t("Most rounds") }}</label>
|
||||
<input class="input" id="crowd_max_rounds" name="max_rounds"
|
||||
type="number" min="1" max="5" step="1" value="{{ crowd.max_rounds }}">
|
||||
<p class="field__hint">{{ t("A round is out and back. Two gives the first model one chance to change its mind after hearing the objections, which is the point of the whole thing; three is where going in circles starts.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="crowd_wall_seconds">{{ t("Longest a turn may take") }}</label>
|
||||
<input class="input" id="crowd_wall_seconds" name="wall_seconds"
|
||||
type="number" min="60" max="7200" step="30" value="{{ crowd.wall_seconds }}">
|
||||
<p class="field__hint">{{ t("Across every speaker, not each. A member whose endpoint has stalled cannot then hold the round open all afternoon.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="collapse_agreement" value="true"
|
||||
{{ 'checked' if crowd.collapse_agreement }}>
|
||||
<span>{{ t('Fold away a short "I agree" on the way back') }}</span>
|
||||
</label>
|
||||
<p class="field__hint">{{ t("The disagreements are what a crowd is for; a column of bubbles saying nothing is what makes somebody switch it off. The text is still there behind a disclosure.") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -293,6 +293,97 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{#
|
||||
Who else answers.
|
||||
|
||||
Beside the tool switches rather than buried in Chat settings, and
|
||||
*inside this form* rather than in the topbar, for one reason each.
|
||||
|
||||
The first: somebody deciding who answers is making the same kind of
|
||||
choice as somebody picking the model, and the first version of this
|
||||
put it only in the Chat settings panel — behind the ⋯ menu, inside a
|
||||
chat that already existed. The owner enabled the feature, went
|
||||
looking, and could not find it. A control nobody can find is a
|
||||
feature nobody has.
|
||||
|
||||
The second: on the new-chat screen there is no chat row to attach
|
||||
anybody to, so the choice has to *ride along with the first message*
|
||||
— which means being a field of this form. That is the same mechanism
|
||||
the scope switches above use, with the same hidden-input trick,
|
||||
because a browser submits only the ticked boxes and `start_chat`
|
||||
needs to know which ones were not.
|
||||
#}
|
||||
{% if crowd_available %}
|
||||
<div class="picker picker--up" data-picker>
|
||||
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
||||
aria-haspopup="menu" aria-expanded="false"
|
||||
aria-label="{{ t('Crowd') }}" title="{{ t('Crowd') }}">
|
||||
{{ icon("users") }}
|
||||
{% if crowd_member_ids %}
|
||||
<span class="composer__count">{{ crowd_member_ids|length + 1 }}</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
|
||||
<div class="picker__menu picker__menu--scope" data-picker-menu role="menu"
|
||||
hidden aria-label="{{ t('Crowd') }}">
|
||||
<p class="picker__lede">
|
||||
{{ t("Tick a model to have it answer after this one, then be asked whether it disagrees.") }}
|
||||
</p>
|
||||
<p class="picker__group">{{ t("Also answering") }}</p>
|
||||
{% if chat %}
|
||||
{# One hidden field for the whole list, always submitted, so
|
||||
unticking the last box still says something -- an absent checkbox
|
||||
carries no signal of its own. #}
|
||||
<input type="hidden" name="crowd_model_ids" value="" form="crowd-form">
|
||||
{% else %}
|
||||
<input type="hidden" name="crowd_model_ids" value="">
|
||||
{% endif %}
|
||||
{% for model in crowd_available %}
|
||||
<label class="picker__option picker__option--toggle">
|
||||
{% if chat %}
|
||||
{# An existing chat: written at once. The verb is on the checkbox
|
||||
and not on `#crowd-form`, because htmx binds a trigger to the
|
||||
annotated element and `change` bubbles through *ancestors* --
|
||||
which a sibling form is not. `form=` scopes the values, and
|
||||
only the values: without it the PATCH would carry the
|
||||
composer's own `content` and `project_dir`, and `update_chat`
|
||||
answers that with a 409. The same reasoning the agent mode
|
||||
select below carries. #}
|
||||
<input type="checkbox" name="crowd_model_ids" value="{{ model.model_id }}"
|
||||
{{ 'checked' if model.model_id in crowd_member_ids }}
|
||||
form="crowd-form"
|
||||
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
|
||||
{% else %}
|
||||
<input type="checkbox" name="crowd_model_ids" value="{{ model.model_id }}"
|
||||
{{ 'checked' if model.model_id in crowd_member_ids }}>
|
||||
{% endif %}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">{{ model.label }}</span>
|
||||
{% if model.description %}
|
||||
<span class="picker__option-note">{{ model.description }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
{% if crowd_member_ids %}
|
||||
{# One sentence and not three, with the numbers as placeholders: a
|
||||
translation puts the parts in its own order, and two of these
|
||||
fragments are not sentences in any language. #}
|
||||
<p class="picker__lede">
|
||||
{{ t("%(models)s models answer each turn, over up to %(rounds)s rounds.",
|
||||
models=crowd_member_ids|length + 1, rounds=crowd_rounds) }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if crowd_skipped %}
|
||||
<p class="picker__lede">
|
||||
{{ t("Skipped, because you cannot reach them any more:") }}
|
||||
<s>{{ crowd_skipped|join(", ") }}</s>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{#
|
||||
@@ -502,6 +593,12 @@
|
||||
hx-patch and not hx-post: there is no POST for a chat, only PATCH, and
|
||||
htmx shows nothing when a request 405s -- which is how these controls
|
||||
spent the first half of their lives doing nothing. #}
|
||||
{% if chat and crowd_available %}
|
||||
{# Empty, and a sibling of the composer's form rather than inside it. See the
|
||||
crowd checkboxes above, and `#agent-mode-form` below, for why both halves
|
||||
of that sentence matter. #}
|
||||
<form id="crowd-form"></form>
|
||||
{% endif %}
|
||||
{% if chat and chat.kind == "agent" %}
|
||||
<form id="agent-mode-form"></form>
|
||||
{% endif %}
|
||||
|
||||
@@ -88,24 +88,24 @@
|
||||
replies: a round produces more bubbles than it has models in it. #}
|
||||
<span class="badge">
|
||||
{% 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" %}
|
||||
on the way back
|
||||
{{ t("on the way back") }}
|
||||
{% else %}
|
||||
closing
|
||||
{{ t("closing") }}
|
||||
{% 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>
|
||||
{% if crowd.get("stopped") %}
|
||||
{# 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. #}
|
||||
<span class="badge badge--warning" title="{{ t('The round ended here') }}">
|
||||
{% if crowd.get("stopped") == "rounds" %}
|
||||
no rounds left
|
||||
{{ t("no rounds left") }}
|
||||
{% elif crowd.get("stopped") == "time" %}
|
||||
out of time
|
||||
{{ t("out of time") }}
|
||||
{% else %}
|
||||
two endpoints failed
|
||||
{{ t("two endpoints failed") }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -177,6 +177,73 @@ def test_the_round_is_recorded_on_every_row(db, started):
|
||||
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):
|
||||
"""So `speaker_for` resolves the pair rather than guessing at the id."""
|
||||
chat = _crowd_chat(db)
|
||||
|
||||
@@ -70,6 +70,87 @@ def _members(db, chat) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
# --- Reachable where somebody would look --------------------------------------
|
||||
#
|
||||
# The feature shipped in 1.6.0 switched on and unreachable: the only control was
|
||||
# inside the Chat settings panel, behind the ⋯ menu, in a chat that already
|
||||
# existed. The owner enabled it, went looking, and reported that there was nothing
|
||||
# to find. A control nobody can find is a feature nobody has, so these assert the
|
||||
# two places it has to be rather than the one place it was.
|
||||
def test_the_composer_offers_the_crowd_in_a_chat(client, db):
|
||||
"""Beside the tool switches, where the comparable decisions are."""
|
||||
chat = _chat(db)
|
||||
page = client.get(f"/chat/{chat.id}").text
|
||||
assert 'name="crowd_model_ids"' in page
|
||||
assert 'form="crowd-form"' in page
|
||||
assert '<form id="crowd-form">' in page
|
||||
|
||||
|
||||
def test_the_composer_offers_the_crowd_before_the_chat_exists(client, db):
|
||||
"""On the new-chat screen there is no row to attach anybody to, so the choice
|
||||
rides along with the first message — the mechanism the scope switches use."""
|
||||
page = client.get("/chat").text
|
||||
assert 'name="crowd_model_ids"' in page
|
||||
# Riding along, so no sibling form and no PATCH: the composer's own POST
|
||||
# carries it.
|
||||
assert '<form id="crowd-form">' not in page
|
||||
assert 'value="second-model"' in page
|
||||
|
||||
|
||||
def test_starting_a_chat_with_a_crowd_keeps_it(client, db):
|
||||
"""The end of that path: the first message creates the chat *and* its crowd."""
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
client.post(
|
||||
"/api/chats/start",
|
||||
data={
|
||||
"content": "Who is right?",
|
||||
"model_id": "main-model",
|
||||
"crowd_model_ids": ["", "second-model", "third-model"],
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
db.expire_all()
|
||||
chat = db.scalars(sa_select(Chat).order_by(Chat.created_at.desc())).first()
|
||||
assert [row.model_id for row in sorted(chat.crowd, key=lambda r: r.position)] == [
|
||||
"second-model",
|
||||
"third-model",
|
||||
]
|
||||
|
||||
|
||||
def test_starting_a_chat_refuses_a_model_the_person_cannot_reach(client, db):
|
||||
"""The same rule as the panel, in the same one function, so there is nowhere
|
||||
for the two to disagree."""
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
group = Group(name="Wheel")
|
||||
db.add(group)
|
||||
restricted = db.scalar(select(Model).where(Model.model_id == "third-model"))
|
||||
restricted.public = False
|
||||
restricted.groups = [group]
|
||||
user = _user(db)
|
||||
user.role = "user"
|
||||
db.commit()
|
||||
|
||||
client.post(
|
||||
"/api/chats/start",
|
||||
data={
|
||||
"content": "Who is right?",
|
||||
"model_id": "main-model",
|
||||
"crowd_model_ids": ["second-model", "third-model"],
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
db.expire_all()
|
||||
chat = db.scalars(sa_select(Chat).order_by(Chat.created_at.desc())).first()
|
||||
assert [row.model_id for row in chat.crowd] == ["second-model"]
|
||||
|
||||
|
||||
def test_the_composer_control_is_absent_while_the_feature_is_off(client, db):
|
||||
settings_store.update(db, {"enabled": False}, key=settings_store.CROWD)
|
||||
assert 'name="crowd_model_ids"' not in client.get("/chat").text
|
||||
|
||||
|
||||
# --- Choosing -----------------------------------------------------------------
|
||||
def test_the_panel_offers_the_other_models(client, db):
|
||||
chat = _chat(db)
|
||||
@@ -200,6 +281,31 @@ def test_a_bubble_on_the_way_out_says_which_speaker_it_is(db):
|
||||
assert "2 of 3" in html
|
||||
|
||||
|
||||
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):
|
||||
chat = _chat(db)
|
||||
html = _bubble(db, chat, phase=crowd_service.PHASE_BACK)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""A grid that reflows can still overflow a phone, and twice it has.
|
||||
|
||||
`repeat(auto-fit, minmax(13rem, 1fr))` puts two 208px cards side by side on a
|
||||
390px screen: `auto-fit` decides how many columns fit, and a track whose minimum
|
||||
is a fixed length never gives that minimum up. The tree's standing rule is to
|
||||
write `minmax(min(100%, 13rem), 1fr)` instead, so the *track* yields rather than
|
||||
the viewport.
|
||||
|
||||
Half of the rule is not the rule. `.suggestions` had `width: 100%` and got the
|
||||
`min(100%, …)` track, and still rendered 455px wide inside a 366px column -- it
|
||||
is a grid item, so it carries `min-width: auto`, which for a grid item means a
|
||||
min-content floor, and a floor beats `width`. Worse, the floor is measured while
|
||||
the percentage is indefinite, so the track falls back to a card's max-content:
|
||||
adding `min(100%, …)` on its own moved the overflow from 428px to 455px.
|
||||
|
||||
So both halves are asserted here, on every auto-fit grid in the stylesheets. A
|
||||
stylesheet cannot see whether a given grid is a flex item on some page today or
|
||||
becomes one next week, and `min-width: 0` costs nothing where it is not needed.
|
||||
|
||||
Found on a phone, on the one screen the screenshot harness had never actually
|
||||
rendered -- `scripts/shoot.py` builds its client without a lifespan, so the
|
||||
suggestion cards were absent from every shot ever taken of the new-chat screen.
|
||||
That is fixed there; this file is the cheap half that runs in the suite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import lembas
|
||||
|
||||
CSS = Path(lembas.__file__).parent / "web/static/css"
|
||||
|
||||
# `[^{}]*` cannot cross a brace, so an `@media` prelude never matches and the
|
||||
# rules nested inside it do.
|
||||
RULE = re.compile(r"([^{}]*)\{([^{}]*)\}")
|
||||
COMMENT = re.compile(r"/\*.*?\*/", re.S)
|
||||
|
||||
|
||||
def _auto_grids() -> list[tuple[str, str, dict[str, str]]]:
|
||||
found = []
|
||||
for path in sorted(CSS.glob("*.css")):
|
||||
text = COMMENT.sub("", path.read_text(encoding="utf-8"))
|
||||
for prelude, body in RULE.findall(text):
|
||||
if "auto-fit" not in body and "auto-fill" not in body:
|
||||
continue
|
||||
declarations = {
|
||||
part.partition(":")[0].strip(): part.partition(":")[2].strip()
|
||||
for part in body.split(";")
|
||||
if ":" in part
|
||||
}
|
||||
found.append((path.name, prelude.strip(), declarations))
|
||||
return found
|
||||
|
||||
|
||||
def test_the_stylesheets_still_have_auto_fit_grids_to_check():
|
||||
# Otherwise the two tests below pass by finding nothing, which is how a
|
||||
# coverage test quietly stops covering anything.
|
||||
assert len(_auto_grids()) >= 4
|
||||
|
||||
|
||||
def test_every_auto_fit_track_can_give_up_its_minimum():
|
||||
offenders = [
|
||||
f"{name} {selector}"
|
||||
for name, selector, declarations in _auto_grids()
|
||||
for value in [declarations.get("grid-template-columns", "")]
|
||||
if "minmax(" in value and "min(100%" not in value
|
||||
]
|
||||
assert not offenders, (
|
||||
"an auto-fit track with a fixed minimum overflows a phone; write "
|
||||
f"minmax(min(100%, X), 1fr): {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_every_auto_fit_grid_drops_its_automatic_minimum_size():
|
||||
offenders = [
|
||||
f"{name} {selector}"
|
||||
for name, selector, declarations in _auto_grids()
|
||||
if declarations.get("min-width") != "0"
|
||||
]
|
||||
assert not offenders, (
|
||||
"a grid item's `min-width: auto` is a min-content floor and beats "
|
||||
f"`width`, so `min(100%, …)` alone does not save it: {offenders}"
|
||||
)
|
||||
Reference in New Issue
Block a user