From dd9e0e94408fecf7d9d9b41566a3ddf8c2e654fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 21 Jul 2026 11:04:13 +0200 Subject: [PATCH] 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
.
- 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) 
---
 CLAUDE.md                                     | 178 +++++++
 README.md                                     | 154 +++++-
 deploy/README.md                              |  80 ++++
 deploy/chat.lan.nginx.conf                    |  57 +++
 deploy/install.sh                             | 115 +++++
 deploy/lembas.service                         |  49 ++
 deploy/update.sh                              |  50 ++
 pyproject.toml                                |   1 +
 scripts/build_artwork.py                      |  17 +-
 scripts/fetch_vendor.py                       | 119 +++++
 scripts/vendor.lock.json                      |  17 +
 src/lembas/api/admin.py                       | 200 ++++++++
 src/lembas/api/auth.py                        | 186 ++++++++
 src/lembas/api/chats.py                       | 290 ++++++++++++
 src/lembas/api/folders.py                     | 118 +++++
 src/lembas/api/pages.py                       | 109 +++++
 src/lembas/api/preferences.py                 |  29 ++
 src/lembas/cli.py                             | 112 +++++
 src/lembas/config.py                          |  22 +-
 src/lembas/main.py                            | 128 +++++
 src/lembas/security/passwords.py              |   2 +-
 src/lembas/services/chat.py                   | 225 +++++++++
 src/lembas/services/llm/openai_client.py      | 245 ++++++++++
 src/lembas/services/markdown.py               | 134 ++++++
 src/lembas/services/sse.py                    |  24 +
 src/lembas/web/static/css/admin.css           | 113 +++++
 src/lembas/web/static/css/app.css             | 444 ++++++++++++++++++
 src/lembas/web/static/css/chat.css            | 336 +++++++++++++
 src/lembas/web/static/css/tokens.css          | 191 ++++++++
 src/lembas/web/static/img/banner.svg          | 264 +++++++++++
 src/lembas/web/static/img/favicon.svg         |  27 ++
 src/lembas/web/static/img/logo-mark.svg       |  49 ++
 src/lembas/web/static/js/app.js               | 162 +++++++
 src/lembas/web/static/vendor/alpine.min.js    |   5 +
 src/lembas/web/static/vendor/htmx-ext-sse.js  | 290 ++++++++++++
 src/lembas/web/static/vendor/htmx.min.js      |   1 +
 src/lembas/web/templates/_macros.html         |  68 +++
 .../web/templates/admin/_connection_row.html  |  98 ++++
 src/lembas/web/templates/admin/_layout.html   |  73 +++
 .../web/templates/admin/connections.html      |  76 +++
 src/lembas/web/templates/admin/models.html    |  51 ++
 src/lembas/web/templates/auth/login.html      |  47 ++
 src/lembas/web/templates/auth/register.html   |  60 +++
 src/lembas/web/templates/base.html            |  43 ++
 src/lembas/web/templates/chat/_message.html   |  93 ++++
 src/lembas/web/templates/chat/_title_oob.html |  10 +
 src/lembas/web/templates/chat/_turn.html      |  11 +
 src/lembas/web/templates/chat/index.html      | 120 +++++
 src/lembas/web/templates/error.html           |  18 +
 .../web/templates/partials/_chat_link.html    |  21 +
 .../web/templates/partials/_folder.html       |  46 ++
 .../web/templates/partials/sidebar.html       |  72 +++
 src/lembas/web/templates/settings.html        |  70 +++
 src/lembas/web/templating.py                  |  58 +++
 tests/conftest.py                             |  98 ++++
 tests/test_auth.py                            | 132 ++++++
 tests/test_chat.py                            | 313 ++++++++++++
 tests/test_crypto.py                          |  67 +++
 tests/test_markdown.py                        |  97 ++++
 59 files changed, 6273 insertions(+), 12 deletions(-)
 create mode 100644 CLAUDE.md
 create mode 100644 deploy/README.md
 create mode 100644 deploy/chat.lan.nginx.conf
 create mode 100755 deploy/install.sh
 create mode 100644 deploy/lembas.service
 create mode 100755 deploy/update.sh
 mode change 100644 => 100755 scripts/build_artwork.py
 create mode 100755 scripts/fetch_vendor.py
 create mode 100644 scripts/vendor.lock.json
 create mode 100644 src/lembas/api/admin.py
 create mode 100644 src/lembas/api/auth.py
 create mode 100644 src/lembas/api/chats.py
 create mode 100644 src/lembas/api/folders.py
 create mode 100644 src/lembas/api/pages.py
 create mode 100644 src/lembas/api/preferences.py
 create mode 100644 src/lembas/cli.py
 create mode 100644 src/lembas/main.py
 create mode 100644 src/lembas/services/chat.py
 create mode 100644 src/lembas/services/llm/openai_client.py
 create mode 100644 src/lembas/services/markdown.py
 create mode 100644 src/lembas/services/sse.py
 create mode 100644 src/lembas/web/static/css/admin.css
 create mode 100644 src/lembas/web/static/css/app.css
 create mode 100644 src/lembas/web/static/css/chat.css
 create mode 100644 src/lembas/web/static/css/tokens.css
 create mode 100644 src/lembas/web/static/img/banner.svg
 create mode 100644 src/lembas/web/static/img/favicon.svg
 create mode 100644 src/lembas/web/static/img/logo-mark.svg
 create mode 100644 src/lembas/web/static/js/app.js
 create mode 100644 src/lembas/web/static/vendor/alpine.min.js
 create mode 100644 src/lembas/web/static/vendor/htmx-ext-sse.js
 create mode 100644 src/lembas/web/static/vendor/htmx.min.js
 create mode 100644 src/lembas/web/templates/_macros.html
 create mode 100644 src/lembas/web/templates/admin/_connection_row.html
 create mode 100644 src/lembas/web/templates/admin/_layout.html
 create mode 100644 src/lembas/web/templates/admin/connections.html
 create mode 100644 src/lembas/web/templates/admin/models.html
 create mode 100644 src/lembas/web/templates/auth/login.html
 create mode 100644 src/lembas/web/templates/auth/register.html
 create mode 100644 src/lembas/web/templates/base.html
 create mode 100644 src/lembas/web/templates/chat/_message.html
 create mode 100644 src/lembas/web/templates/chat/_title_oob.html
 create mode 100644 src/lembas/web/templates/chat/_turn.html
 create mode 100644 src/lembas/web/templates/chat/index.html
 create mode 100644 src/lembas/web/templates/error.html
 create mode 100644 src/lembas/web/templates/partials/_chat_link.html
 create mode 100644 src/lembas/web/templates/partials/_folder.html
 create mode 100644 src/lembas/web/templates/partials/sidebar.html
 create mode 100644 src/lembas/web/templates/settings.html
 create mode 100644 src/lembas/web/templating.py
 create mode 100644 tests/conftest.py
 create mode 100644 tests/test_auth.py
 create mode 100644 tests/test_chat.py
 create mode 100644 tests/test_crypto.py
 create mode 100644 tests/test_markdown.py

diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..08b20ee
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,178 @@
+# CLAUDE.md
+
+Working notes for LLeMbas. Read this before changing anything.
+
+## What it is
+
+A self-hosted web UI for OpenAI-compatible LLM endpoints, written in Python and
+themed after Middle-earth. Server-rendered FastAPI + Jinja + htmx; SQLite;
+no JavaScript build step.
+
+## Commands
+
+```bash
+. .venv/bin/activate
+pip install -e ".[dev]"
+
+lembas serve                        # http://127.0.0.1:8080
+lembas info                         # paths + counts, useful when confused
+lembas secret-key                   # generate LEMBAS_SECRET_KEY
+lembas create-admin                 # create or promote an admin
+
+pytest                              # 70 tests, ~2s
+ruff check .                        # lint (line length 100)
+python scripts/build_artwork.py     # regenerate all SVG artwork
+python scripts/fetch_vendor.py      # verify vendored JS against the lockfile
+```
+
+## Hard rules
+
+These are the constraints the project is built around. Breaking one is a
+redesign, not a tweak.
+
+1. **No Node, no npm, no build step.** Browser libraries are downloaded once by
+   `scripts/fetch_vendor.py`, hash-pinned in `scripts/vendor.lock.json`, and
+   committed under `web/static/vendor/`.
+2. **Nothing loads from a CDN at runtime.** A self-hosted tool must work
+   offline and must not report page views to a third party.
+3. **No hard-coded colours outside `tokens.css`.** Every colour, space and
+   radius resolves through a CSS variable. That is what makes a new theme one
+   new block rather than an audit of every stylesheet.
+4. **No migration tool.** SQLite only, schema created at startup by
+   `init_db()`, which is `CREATE TABLE IF NOT EXISTS` and never alters an
+   existing table. See "Changing the schema" below.
+5. **Secrets never reach the browser.** API keys are Fernet-encrypted at rest
+   and only ever rendered masked.
+6. **Model output is untrusted.** Everything from an endpoint goes through
+   `services/markdown.py` (markdown-it → nh3) or `escape_text()`. Never
+   `|safe` on anything that has not.
+
+## The flavour rule
+
+Middle-earth lives in the **artwork, theme names, empty states, loading lines
+and error pages**. It does not live in the functional UI.
+
+Chats are called *Chats*, not *Tales*. Folders are *Folders*, not *Chapters*.
+Buttons say what they do. Someone who has never read the books must be able to
+use this without a glossary. The two themes are named `moria` and `shire`, and
+the 404 says "Not all those who wander are lost. This page, however, is." —
+that is the right amount.
+
+## Layout
+
+```
+src/lembas/
+  main.py            app factory, lifespan, error handlers
+  config.py          pydantic-settings, all LEMBAS_* variables
+  cli.py             typer entry points
+  api/
+    deps.py          Db / CurrentUser / RequiredUser / AdminUser
+    auth.py          register, login, logout
+    pages.py         full-page routes (chat shell, settings)
+    chats.py         messaging + the SSE stream
+    folders.py       folder CRUD
+    admin.py         connections + models
+    preferences.py   per-user theme
+  db/
+    base.py          Base, UUID/Timestamp mixins
+    session.py       engine, SQLite pragmas, init_db, session_scope
+    models/          user, chat, connection, setting
+  security/          passwords (argon2), sessions
+  services/
+    llm/openai_client.py   httpx streaming + model discovery
+    chat.py          request building, endpoint resolution, titles
+    markdown.py      markdown-it + pygments + nh3
+    crypto.py        Fernet encrypt/decrypt/mask
+    sse.py           event framing
+  web/
+    templating.py    render() -- always use this, not TemplateResponse
+    templates/       Jinja
+    static/          css, js, vendor, img
+assets/              SVG masters (generated)
+deploy/              systemd unit, nginx vhost, install/update scripts
+```
+
+## Things that will bite you
+
+**`render()`, not `TemplateResponse`.** `web/templating.py:render()` injects
+`user`, `theme`, `version` and `allow_signup`. Templates assume they exist. If
+you must call `templates.TemplateResponse` directly (the SSE path does, because
+there is no `Request`), pass `user` explicitly — `chat/_message.html` renders
+both roles and the user branch dereferences it.
+
+**The message template is the state machine.** `chat/_message.html` renders an
+incomplete assistant message as a streaming shell carrying `sse-connect`, and a
+complete one as finished output. That is the *only* thing that starts a
+generation. A consequence worth knowing: loading a page whose last reply is
+unfinished restarts it, which is how a dropped connection recovers.
+
+**SSE framing.** `services/sse.py:event()` splits payloads on newlines into
+several `data:` lines. A raw newline in a single `data:` line truncates the
+event — the failure shows up the first time a model emits a code block.
+
+**Streaming opens its own database session.** `api/chats.py:_generate()` uses
+`session_scope()`, not the request's session, because streaming outlives the
+request handler.
+
+**Escaping is chunk-safe on purpose.** `escape_text()` is `html.escape`, which
+works character by character, so escaping stream chunks separately equals
+escaping the whole string. `nh3.clean_text` would also be safe but escapes
+spaces and slashes, tripling the size of every streamed token.
+
+**The fence renderer is replaced, not configured.** markdown-it's `highlight`
+option re-wraps output in `
` unless the string starts with `` inside our wrapper. `markdown.py` overrides
+`renderer.rules["fence"]` instead. There is a regression test for this.
+
+**SVG `
+    
+    
+    
+    
+    
+    
+    
+  
+  
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+  
+
diff --git a/src/lembas/web/static/img/favicon.svg b/src/lembas/web/static/img/favicon.svg
new file mode 100644
index 0000000..6b4c4aa
--- /dev/null
+++ b/src/lembas/web/static/img/favicon.svg
@@ -0,0 +1,27 @@
+
+  LLeMbas
+  
+    
+      
+      
+      
+    
+    
+      
+      
+      
+    
+    
+      
+    
+  
+  
+  
+    
+    
+    
+  
+
diff --git a/src/lembas/web/static/img/logo-mark.svg b/src/lembas/web/static/img/logo-mark.svg
new file mode 100644
index 0000000..787cb74
--- /dev/null
+++ b/src/lembas/web/static/img/logo-mark.svg
@@ -0,0 +1,49 @@
+
+  LLeMbas
+  A silver mallorn leaf laid across a scored golden lembas wafer.
+  
+    
+      
+      
+      
+    
+    
+      
+      
+      
+    
+    
+      
+    
+  
+  
+  
+    
+      
+      
+    
+    
+      
+      
+    
+  
+  
+  
+    
+    
+    
+    
+      
+      
+      
+      
+      
+      
+    
+  
+
diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js
new file mode 100644
index 0000000..81ed765
--- /dev/null
+++ b/src/lembas/web/static/js/app.js
@@ -0,0 +1,162 @@
+/*
+  Client-side behaviour.
+
+  Everything here is progressive: the application is server-rendered and works
+  without this file, apart from the streaming reply, which is htmx's SSE
+  extension rather than anything hand-written below.
+*/
+(function () {
+  "use strict";
+
+  var THEME_KEY = "lembas-theme";
+  var THEMES = ["moria", "shire"];
+
+  /* --- Theme -------------------------------------------------------------
+     Stored locally so the choice applies instantly and survives being signed
+     out, and mirrored to the server so it follows the user to another device.
+     The server call is best-effort: a failure must not undo the local switch. */
+  function currentTheme() {
+    return document.documentElement.dataset.theme || THEMES[0];
+  }
+
+  function applyTheme(name) {
+    if (THEMES.indexOf(name) === -1) return;
+    document.documentElement.dataset.theme = name;
+    try {
+      localStorage.setItem(THEME_KEY, name);
+    } catch (e) { /* private mode */ }
+
+    document.querySelectorAll("[data-theme-toggle]").forEach(function (el) {
+      el.setAttribute("aria-label", name === "moria" ? "Switch to Shire (light)"
+                                                     : "Switch to Moria (dark)");
+    });
+
+    if (document.body.dataset.authenticated === "true") {
+      fetch("/api/preferences/theme", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ theme: name })
+      }).catch(function () { /* preference is already applied locally */ });
+    }
+  }
+
+  function toggleTheme() {
+    applyTheme(currentTheme() === "moria" ? "shire" : "moria");
+  }
+
+  /* --- Textarea autosize -------------------------------------------------
+     Grows the composer with its content up to a cap, after which it scrolls. */
+  function autosize(el) {
+    if (!el) return;
+    var max = parseInt(el.dataset.maxHeight || "320", 10);
+    el.style.height = "auto";
+    el.style.height = Math.min(el.scrollHeight, max) + "px";
+    el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
+  }
+
+  /* --- Copy --------------------------------------------------------------
+     Falls back to a hidden textarea because navigator.clipboard is unavailable
+     on pages served over plain http, which self-hosted installs often are. */
+  function copyText(text, trigger) {
+    function done() {
+      if (!trigger) return;
+      var original = trigger.getAttribute("aria-label");
+      trigger.classList.add("is-copied");
+      trigger.setAttribute("aria-label", "Copied");
+      setTimeout(function () {
+        trigger.classList.remove("is-copied");
+        if (original) trigger.setAttribute("aria-label", original);
+      }, 1400);
+    }
+
+    if (navigator.clipboard && window.isSecureContext) {
+      navigator.clipboard.writeText(text).then(done).catch(function () {});
+      return;
+    }
+    var scratch = document.createElement("textarea");
+    scratch.value = text;
+    scratch.setAttribute("readonly", "");
+    scratch.style.position = "fixed";
+    scratch.style.opacity = "0";
+    document.body.appendChild(scratch);
+    scratch.select();
+    try { document.execCommand("copy"); done(); } catch (e) { /* nothing to do */ }
+    document.body.removeChild(scratch);
+  }
+
+  /* --- Thread scrolling --------------------------------------------------
+     Only auto-scrolls when the reader is already near the bottom, so scrolling
+     up to re-read something is not yanked away by an incoming token. */
+  var STICK_THRESHOLD = 120;
+
+  function isNearBottom(el) {
+    return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
+  }
+
+  function scrollThread(force) {
+    var thread = document.getElementById("thread-scroll");
+    if (!thread) return;
+    if (force || isNearBottom(thread)) {
+      thread.scrollTop = thread.scrollHeight;
+    }
+  }
+
+  window.lembas = {
+    applyTheme: applyTheme,
+    toggleTheme: toggleTheme,
+    copyText: copyText,
+    scrollThread: scrollThread,
+    autosize: autosize
+  };
+
+  /* --- Wiring ------------------------------------------------------------ */
+  document.addEventListener("click", function (event) {
+    var toggle = event.target.closest("[data-theme-toggle]");
+    if (toggle) {
+      event.preventDefault();
+      toggleTheme();
+      return;
+    }
+
+    var copy = event.target.closest("[data-copy]");
+    if (copy) {
+      event.preventDefault();
+      var source = document.getElementById(copy.dataset.copy);
+      if (source) copyText(source.textContent.trim(), copy);
+    }
+  });
+
+  document.addEventListener("input", function (event) {
+    if (event.target.matches("[data-autosize]")) autosize(event.target);
+  });
+
+  /* Enter sends, Shift+Enter inserts a newline -- the convention every chat
+     application uses. Left alone on touch devices, where there is no easy
+     Shift and Enter should mean "new line". */
+  document.addEventListener("keydown", function (event) {
+    if (event.key !== "Enter" || event.shiftKey) return;
+    var composer = event.target.closest("[data-composer-input]");
+    if (!composer) return;
+    if (window.matchMedia("(pointer: coarse)").matches) return;
+    event.preventDefault();
+    var form = composer.closest("form");
+    if (form && composer.value.trim()) form.requestSubmit();
+  });
+
+  document.addEventListener("DOMContentLoaded", function () {
+    document.querySelectorAll("[data-autosize]").forEach(autosize);
+    scrollThread(true);
+    applyTheme(currentTheme());
+  });
+
+  /* After any htmx swap: re-measure the composer and follow new content. */
+  document.body.addEventListener("htmx:afterSwap", function () {
+    document.querySelectorAll("[data-autosize]").forEach(autosize);
+    scrollThread(false);
+  });
+
+  /* Tokens arriving over SSE are appended outside the normal swap cycle. */
+  document.body.addEventListener("htmx:sseMessage", function () {
+    scrollThread(false);
+  });
+})();
diff --git a/src/lembas/web/static/vendor/alpine.min.js b/src/lembas/web/static/vendor/alpine.min.js
new file mode 100644
index 0000000..ab371ef
--- /dev/null
+++ b/src/lembas/web/static/vendor/alpine.min.js
@@ -0,0 +1,5 @@
+(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function qe(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function Ke(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(In)}}function In(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{qe()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(Ke);t._x_cleanups?.length;)t._x_cleanups.pop()()}var le=new MutationObserver(pe),ue=!1;function ut(){le.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ue=!0}function fe(){kn(),le.disconnect(),ue=!1}var lt=[];function kn(){let t=le.takeRecords();lt.push(()=>t.length>0&&pe(t));let e=lt.length;queueMicrotask(()=>{if(lt.length===e)for(;lt.length>0;)lt.shift()()})}function m(t){if(!ue)return t();fe();let e=t();return ut(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function lr(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message}
+
+${r?'Expression: "'+r+`"
+
+`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return ur(...t)}var ur=()=>{};function fr(t){ur=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>nt(u,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):l.then(u=>{ft(i,u,c,s,r)}).catch(u=>nt(u,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&it?l.apply(o,s):l}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(qn(n,r)).sort(Kn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function qn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function Kn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function E(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `
+
+
+{% include "partials/icons.html" %}
+
+{% block body %}{% endblock %}
+
+
+
+
+
+{% block scripts %}{% endblock %}
+
+
diff --git a/src/lembas/web/templates/chat/_message.html b/src/lembas/web/templates/chat/_message.html
new file mode 100644
index 0000000..51e5fd8
--- /dev/null
+++ b/src/lembas/web/templates/chat/_message.html
@@ -0,0 +1,93 @@
+{% from "_macros.html" import icon, mark %}
+{#
+  One message bubble, in either of two states.
+
+  An incomplete assistant message renders the streaming shell: it carries the
+  sse-connect that opens the reply stream. This is deliberately the ONLY thing
+  that starts a generation, which means a page load showing an unfinished reply
+  picks it up again -- reloading after a dropped connection retries rather than
+  leaving a permanently half-written answer.
+
+  A complete message renders its finished body: Markdown for the assistant,
+  escaped plain text for everyone else.
+#}
+{% set streaming = (message.role == "assistant" and not message.complete) %}
+
+
+ + + +
+
+ + {{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }} + + {% if message.model_id %} + {{ message.model_id }} + {% endif %} +
+ + {% if streaming %} + {# Tokens are appended here as they arrive. The cursor is a CSS + pseudo-element on the empty parent, so it disappears by itself once + the first token lands. #} +
+
+ +
+ {% elif message.error %} + + {% if message.content %} +
{{ body_html|safe }}
+ {% endif %} + {% elif message.role == "assistant" %} +
{{ body_html|safe }}
+ {% else %} +
{{ message.content }}
+ {% endif %} + + {% if not streaming %} +
+ + {% if message.role == "assistant" %} + + {% endif %} +
+ {# The raw source, so the copy button yields Markdown rather than rendered + text. A hidden div and not a world") + assert "click') + assert 'href="javascript:' not in html + + +def test_event_handlers_are_stripped(): + html = render_markdown('') + assert "onerror" not in html + + +def test_external_links_get_protective_rel(): + html = render_markdown("[example](https://example.com)") + assert "noopener" in html + assert "noreferrer" in html + + +def test_code_block_is_highlighted_and_not_double_wrapped(): + html = render_markdown("```python\ndef f():\n return 1\n```") + assert 'class="code-block"' in html + assert "pg-k" in html # a Pygments keyword span + # markdown-it wraps highlight output in its own
 unless the
+    # fence rule is replaced outright. This is the regression guard.
+    assert "
python<" in render_markdown("```python\nx = 1\n```")
+
+
+def test_unlabelled_code_block_still_renders():
+    html = render_markdown("```\njust text\n```")
+    assert 'class="code-block"' in html
+    assert "just text" in html
+
+
+def test_code_content_is_escaped():
+    html = render_markdown("```\n\n```")
+    assert "