Files
LLeMbas/scripts/build_artwork.py
T
Jaroslav Beneš 0f44e8d24c Working chat: auth, connections, streaming, folders
LLeMbas now runs end to end. Register, add an OpenAI-compatible
connection, and hold a real streaming conversation organised into
folders. Verified against the local llama-swap instance.

Streaming is the one genuinely tricky part. Sending a message returns
two HTML fragments -- the user bubble and an empty assistant bubble
carrying an sse-connect -- and that attribute is the ONLY thing that
starts a generation. Rendering an incomplete assistant message as a
streaming shell falls out of the same template, which means loading a
page whose last reply never finished simply picks it up again.

Details worth knowing about, each commented where it matters:

- SSE payloads are split across several data: lines. A raw newline in
  one data: line truncates the event, which shows up the first time a
  model emits a code block.
- Markdown is rendered server-side by the same helper for both the page
  and the final streamed frame, so the two cannot disagree. The fence
  renderer is replaced outright rather than using markdown-it's
  highlight option, which re-wraps output in a second <pre>.
- escape_text is html.escape, not nh3.clean_text: it escapes character
  by character, so escaping stream chunks separately equals escaping
  the whole string.
- The stream opens its own session via session_scope(); it outlives the
  request handler and the dependency-scoped session may be closed.
- Deleting a folder keeps the chats inside it (FK is SET NULL). Losing
  a conversation to a mis-clicked folder delete is unforgivable.
- Login failures use one message for "no such account" and "wrong
  password" so the form cannot enumerate registered addresses.

Also adds deploy/ for the gamebox install at https://chat.lan: system
unit, nginx vhost with buffering off (buffering on turns streaming into
one lump at the end), and install/update scripts following the same
service-user and /srv bind-mount conventions as llama-swap and comfyui.

70 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:04:13 +02:00

487 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate every LLeMbas SVG asset from one source of truth.
Why a generator rather than five hand-written files: the leaf mark appears in
the icon, the favicon, the lockup and the banner. Keeping the geometry in one
place is the only way those stay identical as the mark is tuned.
Why the wordmark is outlines and not <text>: a README banner on GitHub or Gitea
cannot load a webfont, so <text> would render in whatever serif the viewer
happens to have. Outlines look the same everywhere. Letterforms come from
Source Serif 4 (Adobe, SIL OFL 1.1); only the handful of glyphs actually used
are extracted, as a static drawing -- no font binary is redistributed.
This is a design-time tool. The application never imports it, and the generated
files are committed. Re-run it only when the artwork itself changes:
pip install fonttools
python scripts/build_artwork.py
"""
from __future__ import annotations
import argparse
import random
import sys
from pathlib import Path
try:
from fontTools.pens.boundsPen import BoundsPen
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.ttLib import TTFont
except ImportError: # pragma: no cover - design-time tool
sys.exit("fontTools is required for this script: pip install fonttools")
ROOT = Path(__file__).resolve().parent.parent
ASSETS = ROOT / "assets"
STATIC_IMG = ROOT / "src" / "lembas" / "web" / "static" / "img"
# assets/ holds the design masters; the application serves its own copies from
# static/. These are the few the running app actually needs.
SERVED_BY_APP = ("favicon.svg", "logo-mark.svg", "banner.svg")
FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf")
FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf")
WORDMARK = "LLeMbas"
TAGLINE = "Waybread for the long road of thought"
# The capitals of LLeMbas spell LLM. Those three glyphs carry the accent colour.
ACCENT_GLYPHS = frozenset({0, 1, 3})
# --- Palette -----------------------------------------------------------------
GOLD_LIGHT = "#EACB74"
GOLD = "#C9A227"
GOLD_DARK = "#916F13"
GOLD_SCORE = "#7A5C10"
GOLD_HILIGHT = "#F6E3A8"
RUNE_GOLD = "#E0B252"
LEAF_EDGE = "#93A5B6"
LEAF_LIGHT = "#F1F6FA"
LEAF_MID = "#B8C7D5"
LEAF_VEIN = "#61758A"
LEAF_STEM = "#8A9AA8"
NIGHT_TOP = "#080B0F"
NIGHT_MID = "#101822"
NIGHT_LOW = "#1A2530"
PARCHMENT = "#EDE6D6"
INK = "#1B1F23"
MUTED = "#9AA7B4"
# --- The mallorn leaf --------------------------------------------------------
# Drawn once, in a 64x64 box, and reused everywhere. Tuned so the silhouette
# still reads as a leaf at 16px, where veins and score lines disappear.
LEAF_BLADE = (
"M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z"
)
LEAF_MIDRIB = "M20.5 45.5 C28 38 36 29 45.5 18.5"
LEAF_STEM_PATH = "M21.2 44.8 L17 49.4"
LEAF_VEINS = [
"M26.9 38.8 Q25.2 35.8 24.9 32.1",
"M32.3 33.1 Q30.9 30.3 30.4 26.9",
"M37.8 27.0 Q36.6 24.6 36.3 21.7",
"M26.9 38.8 Q30.5 40.1 33.7 40.3",
"M32.3 33.1 Q35.8 34.3 38.6 34.5",
"M37.8 27.0 Q40.8 27.9 43.2 28.1",
]
HEADER = '<svg xmlns="http://www.w3.org/2000/svg"'
# --- Type --------------------------------------------------------------------
class TextRun:
"""A string converted to SVG outlines, positioned with the baseline at y=0."""
def __init__(self, font_path: Path, text: str, cap_px: float):
if not font_path.exists():
sys.exit(f"font not found: {font_path}")
font = TTFont(font_path)
cmap = font.getBestCmap()
glyph_set = font.getGlyphSet()
hmtx = font["hmtx"]
# Scale to cap height rather than em size: it is what the eye measures.
cap_height = getattr(font["OS/2"], "sCapHeight", None) or font["head"].unitsPerEm * 0.7
scale = cap_px / cap_height
self.glyphs: list[dict] = []
pen_x = 0.0
x0 = y0 = float("inf")
x1 = y1 = float("-inf")
for index, char in enumerate(text):
if char == " ":
pen_x += hmtx[cmap[ord(" ")]][0] * scale
continue
name = cmap.get(ord(char))
if name is None:
sys.exit(f"{font_path.name} has no glyph for {char!r}")
# Font coordinates run upwards; SVG's run downwards, hence -scale.
transform = (scale, 0, 0, -scale, pen_x, 0)
svg_pen = SVGPathPen(glyph_set, ntos=lambda v: f"{v:.2f}")
glyph_set[name].draw(TransformPen(svg_pen, transform))
bounds_pen = BoundsPen(glyph_set)
glyph_set[name].draw(TransformPen(bounds_pen, transform))
if bounds_pen.bounds:
gx0, gy0, gx1, gy1 = bounds_pen.bounds
x0, y0 = min(x0, gx0), min(y0, gy0)
x1, y1 = max(x1, gx1), max(y1, gy1)
self.glyphs.append(
{"char": char, "index": index, "path": svg_pen.getCommands()}
)
pen_x += hmtx[name][0] * scale
self.x0, self.y0, self.x1, self.y1 = x0, y0, x1, y1
self.width = x1 - x0
self.height = y1 - y0
def paths(
self,
accent_indices: frozenset[int] = frozenset(),
*,
indent: str = " ",
base_class: str = "base",
accent_class: str = "accent",
) -> str:
"""Render the glyphs, offset so the run's left edge sits at x=0.
Class names are caller-supplied because <style> inside an SVG is scoped
to the whole document, not to the group it sits in. Two runs in one file
sharing a class name means the second rule silently recolours the first.
"""
out = []
for glyph in self.glyphs:
cls = accent_class if glyph["index"] in accent_indices else base_class
out.append(
f'{indent}<path class="{cls}" data-char="{glyph["char"]}" '
f'd="{glyph["path"]}"/>'
)
return "\n".join(out)
@property
def origin_shift(self) -> str:
"""Transform placing the run's top-left at the current origin."""
return f"translate({-self.x0:.2f} {-self.y0:.2f})"
def type_style(indent: str = " ") -> str:
"""Colour rules for outlined type.
The CSS variables let the application theme the type when the SVG is
inlined into a page. The literal fallbacks matter just as much: opened as a
standalone file or loaded through <img>, no page CSS reaches the document,
so the media query is the only thing keeping the wordmark legible on a dark
background.
"""
return f"""{indent}<style>
{indent} .base {{ fill: var(--lembas-ink, {INK}); }}
{indent} .accent {{ fill: var(--lembas-gold, {GOLD}); }}
{indent} @media (prefers-color-scheme: dark) {{
{indent} .base {{ fill: var(--lembas-ink, {PARCHMENT}); }}
{indent} .accent {{ fill: var(--lembas-gold, {RUNE_GOLD}); }}
{indent} }}
{indent}</style>"""
# --- The mark ----------------------------------------------------------------
def mark_defs(prefix: str) -> str:
return f""" <defs>
<linearGradient id="{prefix}-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="{GOLD_LIGHT}"/>
<stop offset="0.5" stop-color="{GOLD}"/>
<stop offset="1" stop-color="{GOLD_DARK}"/>
</linearGradient>
<linearGradient id="{prefix}-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="{LEAF_EDGE}"/>
<stop offset="0.4" stop-color="{LEAF_LIGHT}"/>
<stop offset="1" stop-color="{LEAF_MID}"/>
</linearGradient>
<clipPath id="{prefix}-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/>
</clipPath>
</defs>"""
def mark_body(prefix: str, *, detail: bool = True) -> str:
"""The wafer-and-leaf mark in a 64x64 box.
detail=False drops the score lines, rim and veins for small-size use.
"""
parts = [f' <rect x="6" y="6" width="52" height="52" rx="13" fill="url(#{prefix}-wafer)"/>']
if detail:
parts.append(f""" <g clip-path="url(#{prefix}-clip)" fill="none" stroke-linecap="round">
<g stroke="{GOLD_SCORE}" stroke-opacity="0.38" stroke-width="2">
<path d="M32 6 V58"/>
<path d="M6 32 H58"/>
</g>
<g stroke="{GOLD_HILIGHT}" stroke-opacity="0.3" stroke-width="1">
<path d="M33.2 6 V58"/>
<path d="M6 33.2 H58"/>
</g>
</g>
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9"
fill="none" stroke="{GOLD_SCORE}" stroke-opacity="0.3" stroke-width="1.2"/>""")
parts.append(f""" <g>
<path d="{LEAF_STEM_PATH}" stroke="{LEAF_STEM}" stroke-width="3"
stroke-linecap="round" fill="none"/>
<path d="{LEAF_BLADE}" fill="url(#{prefix}-leaf)"/>
<path d="{LEAF_MIDRIB}" fill="none" stroke="{LEAF_VEIN}" stroke-opacity="0.5"
stroke-width="1.5" stroke-linecap="round"/>""")
if detail:
veins = "\n".join(f' <path d="{v}"/>' for v in LEAF_VEINS)
parts.append(f""" <g fill="none" stroke="{LEAF_VEIN}" stroke-opacity="0.32"
stroke-width="1" stroke-linecap="round">
{veins}
</g>""")
parts.append(" </g>")
return "\n".join(parts)
# --- Asset builders ----------------------------------------------------------
def build_logo_mark() -> str:
return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64"
role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
<desc>A silver mallorn leaf laid across a scored golden lembas wafer.</desc>
{mark_defs("m")}
{mark_body("m")}
</svg>
"""
def build_favicon() -> str:
"""Small-size variant: no score lines or veins, larger blade, tighter tile."""
return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64"
role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
{mark_defs("f")}
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#f-wafer)"/>
<g transform="translate(32 32) scale(1.16) translate(-32 -32)">
<path d="M20.6 44.6 L15.6 50.1" stroke="{LEAF_STEM}" stroke-width="3.4"
stroke-linecap="round" fill="none"/>
<path d="{LEAF_BLADE}" fill="url(#f-leaf)"/>
<path d="{LEAF_MIDRIB}" fill="none" stroke="{LEAF_VEIN}" stroke-opacity="0.45"
stroke-width="1.8" stroke-linecap="round"/>
</g>
</svg>
"""
def build_wordmark() -> str:
"""Standalone type. Inherits colour so it can sit on any background."""
run = TextRun(FONT_SEMIBOLD, WORDMARK, 100)
return f"""{HEADER} viewBox="0 0 {run.width:.2f} {run.height:.2f}"
width="{run.width:.2f}" height="{run.height:.2f}" role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
<!-- Source Serif 4 (SIL OFL 1.1) outlines. The capitals L, L and M spell out
LLM and take the accent colour; see scripts/build_artwork.py. -->
{type_style(" ")}
<g transform="{run.origin_shift}">
{run.paths(ACCENT_GLYPHS, indent=" ")}
</g>
</svg>
"""
def build_lockup() -> str:
"""Horizontal mark + wordmark, for the application header."""
cap = 46.0
run = TextRun(FONT_SEMIBOLD, WORDMARK, cap)
mark_size = 64.0
gap = 20.0
pad = 4.0
height = mark_size + pad * 2
text_x = pad + mark_size + gap
# Optically centre on the cap height rather than the full glyph bounds, so
# the ascender of "b" and the overshoot of "e" do not shift the baseline.
baseline_y = height / 2 + cap / 2
width = text_x + run.width + pad
return f"""{HEADER} viewBox="0 0 {width:.2f} {height:.2f}"
width="{width:.2f}" height="{height:.2f}" role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
{mark_defs("l")}
{type_style(" ")}
<g transform="translate({pad} {pad})">
{mark_body("l")}
</g>
<g transform="translate({text_x - run.x0:.2f} {baseline_y:.2f})">
{run.paths(ACCENT_GLYPHS, indent=" ")}
</g>
</svg>
"""
def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str:
"""One jagged ridge line spanning the full width."""
rng = random.Random(seed)
points = [(0.0, base_y)]
x = 0.0
while x < width:
step = rng.uniform(width * 0.045, width * 0.11)
x = min(x + step, width)
peak = base_y - rng.uniform(height * 0.35, height)
points.append((x, peak))
# A short shoulder after each peak keeps the ridge from looking like a saw.
if x < width:
x = min(x + rng.uniform(width * 0.01, width * 0.03), width)
points.append((x, peak + rng.uniform(height * 0.08, height * 0.25)))
points.append((width, base_y))
coords = " ".join(f"{px:.1f},{py:.1f}" for px, py in points)
return f' <polygon points="{coords} {width:.0f},999 0,999" fill="{colour}"/>'
def _stars(width: float, height: float, count: int, seed: int) -> str:
rng = random.Random(seed)
out = []
for _ in range(count):
sx = rng.uniform(0, width)
sy = rng.uniform(0, height)
r = rng.uniform(0.6, 1.9)
opacity = rng.uniform(0.18, 0.85)
out.append(
f' <circle cx="{sx:.1f}" cy="{sy:.1f}" r="{r:.2f}" opacity="{opacity:.2f}"/>'
)
return "\n".join(out)
def _drifting_leaves(seed: int) -> str:
"""A few mallorn leaves adrift in the sky, well behind the type."""
rng = random.Random(seed)
placements = [
(120, 90, 0.42, -18), (250, 250, 0.30, 24), (1035, 95, 0.36, 12),
(1160, 215, 0.46, -32), (905, 300, 0.26, 40), (185, 300, 0.24, -8),
]
out = []
for cx, cy, scale, rot in placements:
opacity = rng.uniform(0.10, 0.19)
out.append(
f' <g transform="translate({cx} {cy}) rotate({rot}) '
f'scale({scale}) translate(-32 -32)" opacity="{opacity:.2f}">'
f'<path d="{LEAF_BLADE}" fill="{RUNE_GOLD}"/></g>'
)
return "\n".join(out)
def build_banner() -> str:
"""README hero.
Carries its own dark background rather than relying on the page, because a
README is rendered on a light background as often as a dark one.
"""
width, height = 1280.0, 420.0
cap = 92.0
run = TextRun(FONT_SEMIBOLD, WORDMARK, cap)
tag = TextRun(FONT_ITALIC, TAGLINE, 26.0)
mark_size = 136.0
gap = 34.0
lockup_w = mark_size + gap + run.width
lockup_x = (width - lockup_w) / 2
baseline_y = 232.0
mark_y = baseline_y - cap / 2 - mark_size / 2
tag_x = (width - tag.width) / 2 - tag.x0
tag_y = baseline_y + 68.0
mark_scale = mark_size / 64.0
return f"""{HEADER} viewBox="0 0 {width:.0f} {height:.0f}"
width="{width:.0f}" height="{height:.0f}" role="img"
aria-label="LLeMbas - {TAGLINE}">
<title>LLeMbas</title>
<desc>{TAGLINE}. A mallorn leaf and wafer above the mountains at night.</desc>
<defs>
<linearGradient id="b-sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="{NIGHT_TOP}"/>
<stop offset="0.62" stop-color="{NIGHT_MID}"/>
<stop offset="1" stop-color="{NIGHT_LOW}"/>
</linearGradient>
<radialGradient id="b-glow" cx="0.5" cy="0.54" r="0.5">
<stop offset="0" stop-color="{GOLD}" stop-opacity="0.22"/>
<stop offset="1" stop-color="{GOLD}" stop-opacity="0"/>
</radialGradient>
<!-- Cool light sitting just above the ridge line, so the far mountains
separate from the near ones instead of merging into one dark mass. -->
<radialGradient id="b-horizon" cx="0.5" cy="1" r="0.72">
<stop offset="0" stop-color="#4E6C86" stop-opacity="0.30"/>
<stop offset="1" stop-color="#4E6C86" stop-opacity="0"/>
</radialGradient>
{mark_defs("b").removeprefix(" <defs>").removesuffix(" </defs>").rstrip()}
</defs>
<rect width="{width:.0f}" height="{height:.0f}" fill="url(#b-sky)"/>
<g fill="#FFFFFF">
{_stars(width, 300, 130, 11)}
</g>
<rect y="180" width="{width:.0f}" height="240" fill="url(#b-horizon)"/>
<rect width="{width:.0f}" height="{height:.0f}" fill="url(#b-glow)"/>
{_drifting_leaves(5)}
<!-- Ridge lines, furthest first. Each is lighter than the one in front of it,
which is what reads as distance. -->
{_mountains(width, 366, 3, 150, "#1C2836")}
{_mountains(width, 392, 8, 112, "#111A25")}
{_mountains(width, 416, 21, 74, "#080D13")}
<rect y="{height - 5:.0f}" width="{width:.0f}" height="5" fill="{GOLD}" opacity="0.55"/>
<!-- Lockup. Colours are fixed rather than themed: the banner carries its own
night sky, so it must not follow the reader's colour scheme. -->
<g transform="translate({lockup_x:.2f} {mark_y:.2f}) scale({mark_scale:.4f})">
{mark_body("b")}
</g>
<g transform="translate({lockup_x + mark_size + gap - run.x0:.2f} {baseline_y:.2f})">
<style>.base {{ fill: {PARCHMENT}; }} .accent {{ fill: {RUNE_GOLD}; }}</style>
{run.paths(ACCENT_GLYPHS, indent=" ")}
</g>
<g transform="translate({tag_x:.2f} {tag_y:.2f})">
<style>.tag {{ fill: {MUTED}; }}</style>
{tag.paths(indent=" ", base_class="tag")}
</g>
</svg>
"""
# --- Entry point -------------------------------------------------------------
BUILDERS = {
"logo-mark.svg": build_logo_mark,
"favicon.svg": build_favicon,
"wordmark.svg": build_wordmark,
"logo-lockup.svg": build_lockup,
"banner.svg": build_banner,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, default=ASSETS)
parser.add_argument("--only", nargs="*", choices=sorted(BUILDERS), default=None)
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
STATIC_IMG.mkdir(parents=True, exist_ok=True)
for filename in args.only or BUILDERS:
content = BUILDERS[filename]()
path = args.out / filename
path.write_text(content, encoding="utf-8")
print(f"wrote {path.relative_to(ROOT)} ({len(content.encode()):,} bytes)")
if filename in SERVED_BY_APP:
served = STATIC_IMG / filename
served.write_text(content, encoding="utf-8")
print(f" -> {served.relative_to(ROOT)}")
if __name__ == "__main__":
main()