Files
LLeMbas/README.md
T
Jaroslav Beneš bdce2764b1 File attachments: images for vision, PDFs and text into the prompt
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>
2026-07-21 12:19:59 +02:00

170 lines
7.0 KiB
Markdown

<p align="center">
<img src="assets/banner.svg" alt="LLeMbas — waybread for the long road of thought" width="100%">
</p>
<p align="center">
<strong>A self-hosted web UI for your language models, written in Python.</strong><br>
Talks to anything that speaks the OpenAI API. Themed after Middle-earth.
</p>
<p align="center">
<img alt="Python 3.11+" src="https://img.shields.io/badge/python-3.11%2B-3E6B7A?style=flat-square">
<img alt="License GPL-3.0" src="https://img.shields.io/badge/license-GPL--3.0-C9A227?style=flat-square">
<img alt="No Node required" src="https://img.shields.io/badge/build%20step-none-6B8E4E?style=flat-square">
</p>
---
*Lembas* is the Elvish waybread — one bite sustains a traveller for a day's
march. The capitals hide what it runs on: **LLeM**bas.
## Why this exists
Most self-hosted LLM front-ends are large JavaScript applications with a Python
API bolted underneath. LLeMbas is the other way round: **server-rendered
Python**, with htmx and a little Alpine for interactivity. There is no
`package.json`, no bundler, no build step, and nothing is fetched from a CDN at
runtime. Clone it, `pip install -e .`, run it.
## Features
**Working now**
- **Chats** — streaming replies, Markdown with server-side syntax highlighting,
copy and regenerate, automatic chat titles, per-chat system prompt and
sampling settings
- **Reasoning display** — thinking from reasoning models streams into its own
collapsible block, labelled with how long it took, and is never replayed as
context
- **Attachments** — drag, paste or pick images, PDFs and text files. Images are
downscaled and sent to vision models; PDF and text content is extracted and
put in the prompt
- **Folders** — arbitrarily nested, delete a folder without losing the chats
inside it
- **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp,
llama-swap, Ollama or OpenRouter; models are discovered and cached
- **Model settings** — ordering, pinned models, an instance default and a
per-user default, custom names and descriptions, uploaded model images
- **Users, groups & permissions** — per-group grants that union rather than
override, and model access restricted to chosen groups
- **Accounts** — first account becomes the administrator, argon2 password
hashing, revocable server-side sessions, self-service password change,
admin-managed accounts
- **Admin settings** — open or close registration from the UI, stored in the
database and effective immediately
- **Two themes** — *Moria* (dark) and *Shire* (light), switchable per user
**Planned**
Built-in tools with admin settings · custom tools and MCP servers · agentic
execution (local and over SSH) · image generation · OCR for scanned PDFs.
## Quick start
```bash
git clone https://git.houmeres.sk/Houmeres/LLeMbas.git
cd LLeMbas
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env
lembas secret-key # paste the result into LEMBAS_SECRET_KEY
lembas serve # http://127.0.0.1:8080
```
Open the address and create the first account — it becomes the administrator.
Then go to **Admin → Connections** and add an endpoint. For a local runner that
is usually `http://localhost:1234/v1` with no API key. Press **Test & refresh**
and its models appear in the chat model picker.
> The vendored browser libraries (htmx, Alpine) are committed, so no network
> access is needed to run. To re-fetch or bump them:
> `python scripts/fetch_vendor.py --update`.
## Configuration
All variables are prefixed `LEMBAS_` and can live in `.env`. See
[`.env.example`](.env.example) for the annotated list.
| Variable | Default | Purpose |
|---|---|---|
| `LEMBAS_SECRET_KEY` | *generated* | Signs sessions and encrypts stored API keys. **Set this.** A generated key changes every restart, signing everyone out and making stored API keys unreadable. |
| `LEMBAS_DATA_DIR` | `./data` | SQLite database and uploads. |
| `LEMBAS_HOST` / `LEMBAS_PORT` | `127.0.0.1` / `8080` | Bind address. |
| `LEMBAS_ALLOW_SIGNUP` | `true` | Whether new users may register themselves — the *initial* value only. Once set under **Admin → General** the stored setting wins. The first account is always an admin regardless. |
| `LEMBAS_DEFAULT_THEME` | `moria` | `moria` (dark) or `shire` (light). |
| `LEMBAS_SESSION_TTL` | `2592000` | Session lifetime in seconds. |
| `LEMBAS_REQUEST_TIMEOUT` | `300` | Seconds to wait on an upstream model. |
### Commands
```bash
lembas serve # run the server
lembas info # where data lives, what is configured
lembas secret-key # generate a value for LEMBAS_SECRET_KEY
lembas create-admin # create or promote an administrator
```
## How it fits together
```
Browser ──form POST──▶ FastAPI ──▶ SQLite
▲ │
│ └──httpx──▶ any OpenAI-compatible endpoint
└──── server-sent events ◀───────────────┘ (streamed reply)
```
Sending a message stores the turn and returns two HTML fragments: the user's
bubble and an empty assistant bubble carrying an `sse-connect`. That opens a
server-sent event stream which appends tokens as they arrive, then replaces the
whole bubble with the finished, Markdown-rendered version. Rendering and
highlighting happen in Python, so the streamed and final views cannot disagree.
```
src/lembas/
api/ routes: auth, chats, folders, admin, pages
db/models/ SQLAlchemy schema
security/ password hashing, sessions
services/ llm client, chat orchestration, markdown, crypto, sse
web/ Jinja templates and static assets
assets/ SVG artwork masters
scripts/ artwork generator, vendored-JS fetcher
deploy/ systemd unit and nginx vhost for a real install
```
## Development
```bash
pytest # test suite
ruff check . # lint
python scripts/build_artwork.py # regenerate the SVG artwork
python scripts/fetch_vendor.py # verify vendored JS against the lockfile
```
There is no Alembic. The schema is SQLite-only and synchronised at startup:
missing tables and missing columns are added automatically, so adding a field to
a model needs nothing but a restart. Renames, drops and retypes are still manual
— see `CLAUDE.md`.
## Artwork
The logo, favicon and banner are original vector work, generated by
[`scripts/build_artwork.py`](scripts/build_artwork.py) so the mallorn leaf stays
identical across every size it appears at. The wordmark is
[Source Serif 4](https://github.com/adobe-fonts/source-serif) (SIL OFL 1.1)
converted to outlines — a README banner cannot load a webfont, and `<text>`
would render in whatever serif the reader happens to have.
## Licence
[GPL-3.0](LICENSE).
## A note on the theme
This is an independent hobby project, themed as an affectionate nod to
J.R.R. Tolkien's world. It is **not affiliated with, endorsed by, or connected
to** the Tolkien Estate, Middle-earth Enterprises, or any related rights
holder. All artwork here is original.