Reported from a desktop browser: a stray close button beside the logo, badly drawn, and a page that would not scroll. None of it was in the code that was running -- it was the code the browser had not fetched. The worker caches /static/ under a cache named for the release while the files in it carried no version, and a page is fetched network-first. That only ever worked because the worker used to seize every open tab the instant it installed and wipe the old cache. 1.1.0 stopped it doing that, rightly -- it was swapping stylesheets out from under a streaming reply -- and a momentary mismatch became a permanent one: new markup over the previous release's CSS for as long as the old worker lived. `.sidebar__close` had no rule there, so `.btn--icon` made it inline-flex: visible everywhere, placed by nothing. Every /static/ URL carries the release now, written by `templating.asset` and precached by `sw.js:versioned` -- both halves, because caches.match compares the query too and precaching the bare path would cache entries nothing requests. Self-correcting: updating is enough. The header was also a brand with a button appended and margin-left:auto doing the placing, which holds exactly while that button is last. Two slots now: a brand that shrinks and truncates, and a rail on the trailing edge. Verified before changing anything: with the current stylesheet the button is display:none at 1280 and, with thirty chats and forty messages, both scrollers scroll. The first measurement said the thread did not -- that was scroll-behavior: smooth reporting where it started. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
195 lines
7.6 KiB
Python
195 lines
7.6 KiB
Python
"""Jinja environment and the context every template receives."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import Request
|
|
from fastapi.templating import Jinja2Templates
|
|
|
|
from lembas import __version__
|
|
from lembas.config import settings
|
|
from lembas.db.models import User
|
|
from lembas.services import metrics as metrics_service
|
|
from lembas.services import steps as steps_service
|
|
from lembas.services import tool_labels
|
|
from lembas.services.markdown import highlight_tokens
|
|
from lembas.services.reasoning import format_duration
|
|
|
|
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
|
STATIC_DIR = Path(__file__).parent / "static"
|
|
|
|
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
|
|
templates.env.trim_blocks = True
|
|
templates.env.lstrip_blocks = True
|
|
|
|
# {{ message.reasoning_ms | duration }} -> "8 seconds"
|
|
templates.env.filters["duration"] = format_duration
|
|
|
|
# {{ message.usage_json | metrics }} -> a Metrics, so the finished bubble reads
|
|
# its numbers through the same object the live frames are built from.
|
|
templates.env.filters["metrics"] = metrics_service.from_message
|
|
|
|
|
|
def stable_hue(value: str) -> int:
|
|
"""A deterministic 0-359 hue for a string.
|
|
|
|
Used for generated model avatars so each model gets its own colour without
|
|
anyone choosing one, and the same model looks the same on every page and
|
|
after every restart. Python's hash() is salted per process, hence md5.
|
|
"""
|
|
import hashlib
|
|
|
|
digest = hashlib.md5(value.encode("utf-8"), usedforsecurity=False).digest()
|
|
return int.from_bytes(digest[:2], "big") % 360
|
|
|
|
|
|
templates.env.filters["stable_hue"] = stable_hue
|
|
|
|
# A user's own message: escaped here and marked up, so `@mentions` read as
|
|
# references rather than as punctuation. A filter rather than a context value
|
|
# because the message templates are included from four different handlers and
|
|
# every one of them would otherwise have to remember to pass it.
|
|
templates.env.filters["tokens"] = highlight_tokens
|
|
|
|
# What a tool call is called and what it looks like. Globals rather than
|
|
# context values because a message bubble is rendered from four different
|
|
# handlers -- pages, post_message, regenerate and the SSE follower -- and every
|
|
# one of them would otherwise have to remember to pass them. That is the exact
|
|
# trap `audio_service.template_flags` fell into.
|
|
templates.env.globals["tool_label"] = tool_labels.label_for
|
|
templates.env.globals["tool_icon"] = tool_labels.icon_for
|
|
|
|
def asset(path: str) -> str:
|
|
"""A static asset's URL, with the release stamped into it.
|
|
|
|
🚨 This is not cache politeness, it is what stops a release drawing itself
|
|
from two versions at once.
|
|
|
|
The service worker caches `/static/...` under a cache named for the
|
|
release, and a *page* is fetched network-first while its assets come from
|
|
that cache. So the moment the worker stops taking over open tabs the
|
|
instant it installs -- which it must, or it swaps the stylesheets under
|
|
somebody mid-reply -- the new HTML and the old CSS are served together and
|
|
the interface is subtly wrong until the worker is replaced. That shipped in
|
|
1.1.0: a close button intended for a phone drawer appeared, unstyled, on
|
|
every desktop, because the markup knew about it and the stylesheet did not.
|
|
|
|
A version in the URL settles it without anybody having to be careful: the
|
|
new HTML asks for a URL the old cache has never heard of, so it goes to the
|
|
network. The two can no longer disagree, whichever worker is in charge.
|
|
|
|
Not a hash of the file: `__version__` is the one thing that already moves
|
|
with every release, and a hash would mean reading every asset on every
|
|
render or a build step, and there is deliberately no build step here.
|
|
"""
|
|
return f"/static/{path.lstrip('/')}?v={__version__}"
|
|
|
|
|
|
templates.env.globals["asset"] = asset
|
|
|
|
# A finished reply as the sequence of steps it was. A global for exactly the
|
|
# reason the two above are, and it is why turning the bubble into a sequence
|
|
# needed no change in `pages.py`, `post_message`, `regenerate` or the `done`
|
|
# frame -- all four of which render `chat/_message.html`.
|
|
templates.env.globals["message_steps"] = steps_service.for_message
|
|
|
|
|
|
class _Brand:
|
|
"""Whose instance this is, as a Jinja global.
|
|
|
|
A **global** and not a context value, because `render()` has no database
|
|
session and four render paths never reach it at all -- the login page, the
|
|
error pages, the offline page and the SSE fragments. Threading it through
|
|
every one of those would still leave the ones that bypass `render()`.
|
|
|
|
A proxy rather than the snapshot itself, because a global is bound once at
|
|
import and the snapshot changes when an administrator saves. Every attribute
|
|
goes through `branding.snapshot()`, which is a process-level cache: one
|
|
query per process, and one after each save.
|
|
"""
|
|
|
|
def __getattr__(self, name: str):
|
|
from lembas.services import branding
|
|
|
|
return getattr(branding.snapshot(), name)
|
|
|
|
|
|
templates.env.globals["brand"] = _Brand()
|
|
|
|
|
|
def resolve_theme(user: User | None) -> str:
|
|
"""Theme to render with on the server.
|
|
|
|
Only ever a first guess: the inline script in base.html corrects it from
|
|
localStorage before first paint. Getting it close server-side is what stops
|
|
a signed-in user seeing a flash of the wrong theme on every navigation.
|
|
|
|
Validated against the themes that actually exist rather than against a
|
|
hard-coded pair, or a custom theme would be stored on the account, refused
|
|
here, and rendered as Moria on every page load until localStorage corrected
|
|
it -- a flash on every navigation, which is what this function exists to
|
|
prevent.
|
|
"""
|
|
from lembas.services import branding
|
|
|
|
if user is not None:
|
|
chosen = (user.settings_json or {}).get("theme")
|
|
if chosen in branding.snapshot().theme_ids:
|
|
return chosen
|
|
return settings.default_theme
|
|
|
|
|
|
def resolve_layout(user: User | None) -> str:
|
|
"""Stored panel widths as a `style` value for <html>, or "".
|
|
|
|
Same shape as the theme and for the same reason: a first guess, corrected
|
|
from localStorage before first paint. This is what carries a dragged width
|
|
to a second browser, where localStorage has nothing to say.
|
|
|
|
Re-clamped on the way out rather than trusted from the column. The bounds
|
|
could have tightened since it was stored, and a width outside them is a
|
|
panel somebody cannot see well enough to drag back.
|
|
"""
|
|
if user is None:
|
|
return ""
|
|
from lembas.api.preferences import LAYOUT_BOUNDS
|
|
|
|
parts = []
|
|
for name, raw in ((user.settings_json or {}).get("layout") or {}).items():
|
|
bounds = LAYOUT_BOUNDS.get(str(name))
|
|
if bounds is None:
|
|
continue
|
|
try:
|
|
value = min(max(int(float(raw)), bounds[0]), bounds[1])
|
|
except (TypeError, ValueError):
|
|
continue
|
|
parts.append(f"{name}:{value}px")
|
|
return ";".join(parts)
|
|
|
|
|
|
def render(
|
|
request: Request,
|
|
template: str,
|
|
context: dict[str, Any] | None = None,
|
|
**kwargs: Any,
|
|
):
|
|
"""Render a template with the globals every page expects.
|
|
|
|
Using this instead of templates.TemplateResponse directly is what
|
|
guarantees `user` and `theme` are always defined, so templates never need
|
|
to guard against a missing variable.
|
|
"""
|
|
user = getattr(request.state, "user", None)
|
|
payload: dict[str, Any] = {
|
|
"request": request,
|
|
"user": user,
|
|
"theme": resolve_theme(user),
|
|
"layout": resolve_layout(user),
|
|
"version": __version__,
|
|
"allow_signup": settings.allow_signup,
|
|
}
|
|
payload.update(context or {})
|
|
return templates.TemplateResponse(request, template, payload, **kwargs)
|