Files
LLeMbas/src/lembas/web/templating.py
T
HomerandClaude Opus 5.5 da43bc1459 Model lists you can read
The chat's model picker shows name, context window (CTX 131K) and an eye
for vision, on shared column tracks; the capability tags are gone from it.
Settings -> Models gives the name its own row and wraps the tags beneath.
The picker's tick now follows an in-place choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:54:31 +00:00

231 lines
9.3 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
from lembas.web import i18n
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
# {{ t("Save") }} -> "Uložiť". A global and not a context value, for the reason
# `brand` below gives at length: `render()` is bypassed by 25 direct
# `TemplateResponse` calls and 8 `get_template().render()` calls, and the second
# group is the SSE frame path, which has no `Request` at all. The active language
# comes from a ContextVar rather than from here, because a global is bound once at
# import and the language is per person -- see `web/i18n/__init__.py`.
templates.env.globals["t"] = i18n.t
# For `<html lang>` and `<html dir>`, answered from one place so a right-to-left
# language added later is not forgotten in the template.
templates.env.globals["language"] = i18n.active
templates.env.globals["text_direction"] = i18n.direction
# {{ stamp(row.created_at, "%d %B %Y") }} -> a date whose month name is in the
# reader's language. `strftime` alone is C-locale English on every page.
templates.env.globals["stamp"] = i18n.stamp
# {{ 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
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
# 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)