Drag, paste or pick a file in the composer. Images go to vision models
as multimodal content parts; PDFs and text files have their content
extracted and placed in the prompt. Verified end to end against
gemma4-e4b-q8 on llama-swap: given a drawing and a text file, it named
the red square and blue circle and read the number out of the document.
Type is decided by inspecting the bytes, never the filename or the
browser's Content-Type -- a .png full of text is stored as text. Images
are downscaled to 1400px and re-encoded: a phone photo is several
megabytes of base64, which is slow and a large slice of the context
window. PDF text is extracted once, at upload, and stored; re-extracting
per request would let a reply change because a parser was upgraded.
Design points worth keeping:
- Images are only sent to models an administrator has marked `vision`.
This is not graceful degradation -- most endpoints reject the entire
request rather than ignoring an image part. A plain text turn stays a
plain string for the same reason: the list form 400s on endpoints that
do not implement it.
- Images reach the model as base64 data URIs, not links. A local
endpoint has no route back to LLeMbas, and a hosted one has no
credentials for it.
- Non-images are served Content-Disposition: attachment with nosniff, so
an uploaded .html can never execute in this origin. Stored names are
random; the uploader's name is a label and never a path.
- Uploads are unbound until the message is sent, which is what lets a
file be removed beforehand. claim() only takes unclaimed rows owned by
the sender, so a forged id cannot pull in someone else's file.
Abandoned uploads are swept at startup.
- A scanned PDF says so rather than silently contributing nothing, and
truncation is declared to the model in the document tag so it can
admit it did not see page 400.
- "Here, look at this" with no words is a legitimate turn, so a message
is only empty when it carries neither text nor files.
Also fixes auto-titling, which read message["content"] as a string and
would have broken on the first multimodal turn.
186 tests, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Establish the LLeMbas foundation: FastAPI/Jinja/SQLite layout, the ORM
schema, and the original SVG identity.
Notable decisions, all recorded in comments at the point they matter:
- No Alembic. SQLite only, schema created at startup, so models carry a
few columns nothing reads yet (Message.parent_id for branching,
content_parts_json for multimodal turns). Adding them later to a live
database without migrations is the painful path.
- Sessions are server-side rows keyed by a SHA-256 of the cookie value,
not JWTs, so logout and bans revoke access immediately.
- Upstream API keys are Fernet-encrypted with a key derived from
LEMBAS_SECRET_KEY. decrypt() fails soft to "" so rotating the secret
degrades to re-entering keys rather than crashing the admin UI.
- Artwork is generated by scripts/build_artwork.py rather than hand-drawn
per file: the mallorn leaf appears in the icon, favicon, lockup and
banner, and one source is the only way those stay in sync. The wordmark
is Source Serif 4 (OFL) converted to outlines, because a README banner
cannot load a webfont and <text> would render in whatever serif the
viewer happens to have.
- Icons live in a template partial, not assets/, because same-document
<use href="#id"> is universally supported and the cross-document form
is not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>