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>
231 lines
9.3 KiB
Python
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)
|