"""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 , 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)