Compare commits
1 Commits
main
...
dd9e0e9440
| Author | SHA1 | Date | |
|---|---|---|---|
| dd9e0e9440 |
@@ -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 `<pre><code>` unless the string starts with `<pre`,
|
||||||
|
which would nest a second `<pre>` inside our wrapper. `markdown.py` overrides
|
||||||
|
`renderer.rules["fence"]` instead. There is a regression test for this.
|
||||||
|
|
||||||
|
**SVG `<style>` is document-scoped.** Two text runs in one SVG sharing a class
|
||||||
|
name means the later rule recolours both. `build_artwork.py` takes class names
|
||||||
|
as parameters for exactly this reason.
|
||||||
|
|
||||||
|
**Gradient ids are document-global.** The `mark()` macro takes a `uid` because
|
||||||
|
two marks on one page with identical ids make the second silently reuse the
|
||||||
|
first one's gradients.
|
||||||
|
|
||||||
|
**JSON columns need reassignment.** `user.settings_json["theme"] = x` on a
|
||||||
|
plain dict is not detected. The columns use `MutableDict` (`db/types.py`), but
|
||||||
|
the safe habit is `obj.field = {**obj.field, "k": v}`.
|
||||||
|
|
||||||
|
## Changing the schema
|
||||||
|
|
||||||
|
There is no Alembic. `init_db()` creates missing tables and nothing else, so
|
||||||
|
adding a column to a model does **not** add it to an existing database. For a
|
||||||
|
live install: `ALTER TABLE` by hand, or delete the database if the data is
|
||||||
|
disposable.
|
||||||
|
|
||||||
|
This is why `Message.parent_id` and `Message.content_parts_json` already exist
|
||||||
|
though nothing reads them — they are for branching and multimodal turns, and
|
||||||
|
retrofitting them later would be the painful path.
|
||||||
|
|
||||||
|
## Artwork
|
||||||
|
|
||||||
|
Do not hand-edit files in `assets/` — they are generated. Change
|
||||||
|
`scripts/build_artwork.py` and re-run it. It also copies the few files the app
|
||||||
|
serves into `web/static/img/`.
|
||||||
|
|
||||||
|
The leaf geometry is defined once (`LEAF_BLADE`, `LEAF_MIDRIB`, …) and reused by
|
||||||
|
the icon, favicon, lockup and banner. The 64×64 mark must stay legible at 16px:
|
||||||
|
the favicon variant drops the score lines, rim and veins because they turn to
|
||||||
|
mud at that size. The icon sprite is a **template partial**
|
||||||
|
(`templates/partials/icons.html`), not an asset, because same-document
|
||||||
|
`<use href="#id">` is universally supported and the cross-document form is not.
|
||||||
|
|
||||||
|
The `mark()` macro in `_macros.html` duplicates the mark geometry so it can be
|
||||||
|
inlined and themed. If the mark changes, update both.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
`deploy/` holds the systemd unit and nginx vhost for the gamebox install at
|
||||||
|
`https://chat.lan`. See `deploy/README.md`. Service user `lembas`, home
|
||||||
|
`/home/lembas` bind-mounted to `/srv/lembas`, mirroring the llama-swap and
|
||||||
|
comfyui conventions on that machine.
|
||||||
|
|
||||||
|
## Not built yet
|
||||||
|
|
||||||
|
Users & groups UI (the tables exist), file upload / vision / PDFs, built-in
|
||||||
|
tools + admin tool settings, custom tools and MCP, agentic execution (local
|
||||||
|
subprocess and SSH connection profiles), image generation. Empty packages and
|
||||||
|
nav entries mark where each one goes.
|
||||||
@@ -1,2 +1,154 @@
|
|||||||
# LLeMbas
|
<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
|
||||||
|
- **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
|
||||||
|
- **Accounts** — first account becomes the administrator, argon2 password
|
||||||
|
hashing, revocable server-side sessions
|
||||||
|
- **Two themes** — *Moria* (dark) and *Shire* (light), switchable per user
|
||||||
|
|
||||||
|
**Planned**
|
||||||
|
|
||||||
|
Users & groups with permissions · file upload, vision and PDFs · built-in tools
|
||||||
|
with admin settings · custom tools and MCP servers · agentic execution (local
|
||||||
|
and over SSH) · image generation.
|
||||||
|
|
||||||
|
## 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` | Let new users register themselves. 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 migration tool. The schema is SQLite-only and created at startup,
|
||||||
|
so changing a column on a live database is a manual job — 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.
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
Installs LLeMbas as a **system** service behind nginx at `https://chat.lan`.
|
||||||
|
|
||||||
|
Written for `gamebox` (Arch), and follows the conventions already used there
|
||||||
|
for llama-swap and comfyui:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Service user | `lembas` (system account, `nologin`) |
|
||||||
|
| Home | `/home/lembas`, bind-mounted to `/srv/lembas` |
|
||||||
|
| Checkout | `/srv/lembas/app` (git clone of the Gitea remote) |
|
||||||
|
| Virtualenv | `/srv/lembas/venv` |
|
||||||
|
| Database | `/srv/lembas/data/lembas.db` |
|
||||||
|
| Environment | `/srv/lembas/lembas.env` (mode 600) |
|
||||||
|
| Unit | `/etc/systemd/system/lembas.service` |
|
||||||
|
| Vhost | `/etc/nginx/conf.d/chat.lan.conf`, self-signed cert |
|
||||||
|
| Listens | `127.0.0.1:8080` — reachable only through nginx |
|
||||||
|
|
||||||
|
The home lives on `/home` rather than `/var/lib` because the root LV on that
|
||||||
|
box is only 50 GB; `/srv/lembas` is the same bind-mount trick as `/srv/llama`
|
||||||
|
and `/srv/comfyui`.
|
||||||
|
|
||||||
|
## First install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotent — safe to re-run. It creates the user and bind mount, clones the
|
||||||
|
repo, builds the venv, generates `lembas.env` with a fresh
|
||||||
|
`LEMBAS_SECRET_KEY`, installs the unit and vhost, issues a self-signed cert,
|
||||||
|
adds a `/etc/hosts` entry, and enables the service.
|
||||||
|
|
||||||
|
Then open <https://chat.lan>, accept the self-signed certificate warning, and
|
||||||
|
create the first account — it becomes the administrator.
|
||||||
|
|
||||||
|
## Deploying a change
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push # from the working copy
|
||||||
|
./deploy/update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`update.sh` fetches, hard-resets `/srv/lembas/app` to `origin/main`, reinstalls
|
||||||
|
dependencies and restarts the service, then prints what changed. The hard reset
|
||||||
|
is deliberate: nothing is ever edited in place there, so there is no local work
|
||||||
|
to preserve and no merge conflicts to resolve.
|
||||||
|
|
||||||
|
## Operating it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status lembas
|
||||||
|
journalctl -u lembas -f
|
||||||
|
sudo -u lembas /srv/lembas/venv/bin/lembas info # paths and counts
|
||||||
|
sudo systemctl restart lembas
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration lives in `/srv/lembas/lembas.env`. Edit it and restart.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
**The secret key is generated once.** `install.sh` will not overwrite an
|
||||||
|
existing `lembas.env`. Rotating `LEMBAS_SECRET_KEY` signs every user out *and*
|
||||||
|
makes stored upstream API keys unreadable — they would have to be re-entered.
|
||||||
|
|
||||||
|
**nginx buffering is off for a reason.** Replies stream as server-sent events.
|
||||||
|
With `proxy_buffering on` (the default) nginx holds the entire reply and
|
||||||
|
delivers it in one lump at the end, which is indistinguishable from streaming
|
||||||
|
being broken. `proxy_read_timeout` is raised to an hour because a model can
|
||||||
|
think for minutes before the first token.
|
||||||
|
|
||||||
|
**Name resolution.** `chat.lan` is in Pi-hole, but this box queries the router
|
||||||
|
first and the router's dnsmasq is authoritative for `.lan` without forwarding
|
||||||
|
those queries on — the same reason `comfy.lan` needs one. `install.sh` adds a
|
||||||
|
`/etc/hosts` entry, which is harmless if DNS already answers.
|
||||||
|
|
||||||
|
**Hardening is deliberately moderate.** `ProtectSystem=full`, not `strict`: the
|
||||||
|
agentic features planned for later need to run commands, and a lockdown that
|
||||||
|
has to be torn out again is worse than one that was never applied.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# chat.lan - HTTPS reverse proxy to LLeMbas (127.0.0.1:8080).
|
||||||
|
# Deployed to /etc/nginx/conf.d/chat.lan.conf. Self-signed cert (chat.lan).
|
||||||
|
#
|
||||||
|
# Mirrors the comfy.lan and llama.lan vhosts on this box.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name chat.lan;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name chat.lan;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/chat.lan.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/chat.lan.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
# File uploads land here once that feature exists; 0 = no limit.
|
||||||
|
client_max_body_size 0;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Streamed replies are server-sent events. Every one of these matters:
|
||||||
|
# with buffering on, nginx holds the whole reply and delivers it in one
|
||||||
|
# lump at the end, which looks exactly like streaming being broken.
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
# SSE is plain HTTP/1.1 chunked, so the connection header must not be
|
||||||
|
# the websocket upgrade dance -- it must simply stay open.
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
# A model can think for minutes before the first token. The default
|
||||||
|
# 60s read timeout would cut long generations off mid-sentence.
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static assets are immutable per release and never need revalidating.
|
||||||
|
location /static/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
expires 1h;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+115
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install LLeMbas as a system service behind nginx at https://chat.lan.
|
||||||
|
#
|
||||||
|
# Follows the conventions already used on this box for llama-swap and comfyui:
|
||||||
|
# a dedicated service user whose home lives on /home (the root LV is only
|
||||||
|
# 50 GB) and is bind-mounted to /srv/<name>, a system unit so it survives
|
||||||
|
# logout, and an nginx vhost with a self-signed cert.
|
||||||
|
#
|
||||||
|
# Idempotent: safe to re-run. To deploy new code afterwards use update.sh,
|
||||||
|
# which is what a `git push` should be followed by.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_URL="${LEMBAS_REPO_URL:-https://git.houmeres.sk/Houmeres/LLeMbas.git}"
|
||||||
|
BRANCH="${LEMBAS_BRANCH:-main}"
|
||||||
|
SERVICE_USER=lembas
|
||||||
|
HOME_DIR=/home/lembas
|
||||||
|
PREFIX=/srv/lembas
|
||||||
|
APP="$PREFIX/app"
|
||||||
|
VENV="$PREFIX/venv"
|
||||||
|
ENV_FILE="$PREFIX/lembas.env"
|
||||||
|
HERE="$(dirname "$(readlink -f "$0")")"
|
||||||
|
|
||||||
|
echo "== service user =="
|
||||||
|
# --system: no ageing, no mail spool. Home under /home, not /var/lib, so the
|
||||||
|
# venv and database sit on the big volume.
|
||||||
|
if ! getent passwd "$SERVICE_USER" >/dev/null; then
|
||||||
|
sudo useradd --system --create-home --home-dir "$HOME_DIR" \
|
||||||
|
--shell /usr/bin/nologin --comment "LLeMbas" "$SERVICE_USER"
|
||||||
|
else
|
||||||
|
echo " user $SERVICE_USER already exists"
|
||||||
|
fi
|
||||||
|
sudo chmod 755 "$HOME_DIR"
|
||||||
|
|
||||||
|
echo "== /srv/lembas bind-mount onto /home =="
|
||||||
|
sudo mkdir -p "$PREFIX"
|
||||||
|
grep -q "^$HOME_DIR[[:space:]]" /etc/fstab \
|
||||||
|
|| echo "$HOME_DIR $PREFIX none bind 0 0" | sudo tee -a /etc/fstab >/dev/null
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
mountpoint -q "$PREFIX" || sudo mount "$PREFIX"
|
||||||
|
|
||||||
|
echo "== checkout =="
|
||||||
|
if [[ ! -d "$APP/.git" ]]; then
|
||||||
|
sudo -u "$SERVICE_USER" git clone --branch "$BRANCH" "$REPO_URL" "$APP"
|
||||||
|
else
|
||||||
|
echo " already cloned; use update.sh to pull"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== virtualenv =="
|
||||||
|
if [[ ! -x "$VENV/bin/python" ]]; then
|
||||||
|
sudo -u "$SERVICE_USER" python -m venv "$VENV"
|
||||||
|
fi
|
||||||
|
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip
|
||||||
|
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP"
|
||||||
|
|
||||||
|
echo "== environment =="
|
||||||
|
# Generated once and never regenerated: rotating LEMBAS_SECRET_KEY would sign
|
||||||
|
# every user out and make the stored API keys unreadable.
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
KEY=$("$VENV/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))")
|
||||||
|
sudo tee "$ENV_FILE" >/dev/null <<EOF
|
||||||
|
# LLeMbas service environment. Generated by deploy/install.sh.
|
||||||
|
# LEMBAS_SECRET_KEY signs sessions and encrypts stored API keys.
|
||||||
|
# Changing it signs everyone out and makes stored API keys unreadable.
|
||||||
|
LEMBAS_SECRET_KEY=$KEY
|
||||||
|
LEMBAS_DATA_DIR=$PREFIX/data
|
||||||
|
# Loopback only: reachable through the nginx chat.lan vhost, never direct.
|
||||||
|
LEMBAS_HOST=127.0.0.1
|
||||||
|
LEMBAS_PORT=8080
|
||||||
|
LEMBAS_LOG_LEVEL=info
|
||||||
|
LEMBAS_ALLOW_SIGNUP=true
|
||||||
|
LEMBAS_DEFAULT_THEME=moria
|
||||||
|
EOF
|
||||||
|
sudo chown "$SERVICE_USER:$SERVICE_USER" "$ENV_FILE"
|
||||||
|
sudo chmod 600 "$ENV_FILE"
|
||||||
|
echo " generated $ENV_FILE"
|
||||||
|
else
|
||||||
|
echo " $ENV_FILE exists, keeping it (and its secret key)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 750 "$PREFIX/data"
|
||||||
|
|
||||||
|
echo "== systemd unit =="
|
||||||
|
sudo install -Dm644 "$HERE/lembas.service" /etc/systemd/system/lembas.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
echo "== self-signed cert for chat.lan =="
|
||||||
|
sudo mkdir -p /etc/nginx/ssl
|
||||||
|
if [[ ! -f /etc/nginx/ssl/chat.lan.crt ]]; then
|
||||||
|
sudo openssl req -x509 -newkey rsa:2048 -nodes \
|
||||||
|
-keyout /etc/nginx/ssl/chat.lan.key -out /etc/nginx/ssl/chat.lan.crt \
|
||||||
|
-days 3650 -subj "/CN=chat.lan" -addext "subjectAltName=DNS:chat.lan"
|
||||||
|
sudo chmod 600 /etc/nginx/ssl/chat.lan.key
|
||||||
|
sudo chmod 644 /etc/nginx/ssl/chat.lan.crt
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== nginx vhost =="
|
||||||
|
sudo install -Dm644 "$HERE/chat.lan.nginx.conf" /etc/nginx/conf.d/chat.lan.conf
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
|
||||||
|
echo "== local name resolution =="
|
||||||
|
# chat.lan is in Pi-hole, but this box asks the router first and the router's
|
||||||
|
# dnsmasq is authoritative for .lan without forwarding it on -- same reason
|
||||||
|
# comfy.lan needs a hosts entry. Harmless if DNS already resolves it.
|
||||||
|
grep -q 'chat\.lan' /etc/hosts \
|
||||||
|
|| printf '127.0.0.1\tchat.lan\n::1\t\tchat.lan\n' | sudo tee -a /etc/hosts >/dev/null
|
||||||
|
|
||||||
|
echo "== enable service =="
|
||||||
|
sudo systemctl enable --now lembas
|
||||||
|
sleep 2
|
||||||
|
sudo systemctl --no-pager --lines=0 status lembas || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "LLeMbas is up at https://chat.lan (self-signed cert; accept the warning)"
|
||||||
|
echo "Create the first account -- it becomes the administrator."
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# LLeMbas system service.
|
||||||
|
#
|
||||||
|
# Deployed to /etc/systemd/system/lembas.service by deploy/install.sh.
|
||||||
|
#
|
||||||
|
# A system unit, not a user unit, so it survives logout and comes up at boot
|
||||||
|
# without anyone signing in -- matching llama-swap and comfyui on this box.
|
||||||
|
#
|
||||||
|
# /srv/lembas is a bind mount of /home/lembas: the root LV is only 50 GB and
|
||||||
|
# the venv plus SQLite database belong on /home, same trick as /srv/llama.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=LLeMbas - web UI for language models
|
||||||
|
Documentation=https://git.houmeres.sk/Houmeres/LLeMbas
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
# The unit is useless without the bind mount: the venv and database live there.
|
||||||
|
RequiresMountsFor=/srv/lembas
|
||||||
|
# Not a hard dependency. LLeMbas starts fine with the endpoint down and shows a
|
||||||
|
# readable error in the admin UI, which is better than refusing to boot.
|
||||||
|
After=llama-swap.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lembas
|
||||||
|
Group=lembas
|
||||||
|
WorkingDirectory=/srv/lembas/app
|
||||||
|
EnvironmentFile=/srv/lembas/lembas.env
|
||||||
|
ExecStart=/srv/lembas/venv/bin/lembas serve
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Reachable only through the nginx chat.lan vhost, never directly on the LAN.
|
||||||
|
# The bind address is set by LEMBAS_HOST in the environment file.
|
||||||
|
|
||||||
|
# --- Hardening -------------------------------------------------------------
|
||||||
|
# Modest rather than maximal: the agentic features planned for later will need
|
||||||
|
# to run commands, so ProtectSystem=strict would only be torn out again.
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
# Only /srv/lembas needs to be writable; /home/lembas is the same inode.
|
||||||
|
ReadWritePaths=/srv/lembas
|
||||||
|
LimitNOFILE=65535
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pull the latest LLeMbas and restart the service.
|
||||||
|
#
|
||||||
|
# This is what to run after pushing: it fetches, hard-resets the deployment
|
||||||
|
# checkout to the remote branch, reinstalls dependencies if they changed, and
|
||||||
|
# restarts. Nothing is ever edited in place at /srv/lembas/app, so a hard reset
|
||||||
|
# is safe and avoids merge conflicts from a dirty deployment tree.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVICE_USER=lembas
|
||||||
|
PREFIX=/srv/lembas
|
||||||
|
APP="$PREFIX/app"
|
||||||
|
VENV="$PREFIX/venv"
|
||||||
|
BRANCH="${LEMBAS_BRANCH:-main}"
|
||||||
|
|
||||||
|
if [[ ! -d "$APP/.git" ]]; then
|
||||||
|
echo "No deployment at $APP. Run deploy/install.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
before=$(sudo -u "$SERVICE_USER" git -C "$APP" rev-parse HEAD)
|
||||||
|
|
||||||
|
echo "== fetching =="
|
||||||
|
sudo -u "$SERVICE_USER" git -C "$APP" fetch --quiet origin "$BRANCH"
|
||||||
|
sudo -u "$SERVICE_USER" git -C "$APP" reset --hard --quiet "origin/$BRANCH"
|
||||||
|
|
||||||
|
after=$(sudo -u "$SERVICE_USER" git -C "$APP" rev-parse HEAD)
|
||||||
|
|
||||||
|
if [[ "$before" == "$after" ]]; then
|
||||||
|
echo " already at $(git -C "$APP" rev-parse --short HEAD), nothing to pull"
|
||||||
|
else
|
||||||
|
echo " $(echo "$before" | cut -c1-7) -> $(echo "$after" | cut -c1-7)"
|
||||||
|
sudo -u "$SERVICE_USER" git -C "$APP" --no-pager log --oneline "$before..$after" | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cheap and idempotent; catches a dependency added since the last deploy.
|
||||||
|
echo "== dependencies =="
|
||||||
|
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP"
|
||||||
|
|
||||||
|
echo "== restart =="
|
||||||
|
sudo systemctl restart lembas
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if systemctl is-active --quiet lembas; then
|
||||||
|
echo " lembas is running at https://chat.lan"
|
||||||
|
else
|
||||||
|
echo " lembas FAILED to start:" >&2
|
||||||
|
sudo journalctl -u lembas -n 30 --no-pager >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -30,6 +30,7 @@ dependencies = [
|
|||||||
"cryptography>=43.0",
|
"cryptography>=43.0",
|
||||||
"markdown-it-py>=3.0",
|
"markdown-it-py>=3.0",
|
||||||
"mdit-py-plugins>=0.4",
|
"mdit-py-plugins>=0.4",
|
||||||
|
"linkify-it-py>=2.0", # bare URLs in model output become links
|
||||||
"pygments>=2.18",
|
"pygments>=2.18",
|
||||||
"nh3>=0.2.18",
|
"nh3>=0.2.18",
|
||||||
"typer>=0.12",
|
"typer>=0.12",
|
||||||
|
|||||||
Regular → Executable
+15
-2
@@ -35,6 +35,11 @@ except ImportError: # pragma: no cover - design-time tool
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
ASSETS = ROOT / "assets"
|
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_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf")
|
||||||
FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf")
|
FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf")
|
||||||
@@ -463,10 +468,18 @@ def main() -> None:
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
args.out.mkdir(parents=True, exist_ok=True)
|
args.out.mkdir(parents=True, exist_ok=True)
|
||||||
|
STATIC_IMG.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
for filename in args.only or BUILDERS:
|
for filename in args.only or BUILDERS:
|
||||||
|
content = BUILDERS[filename]()
|
||||||
path = args.out / filename
|
path = args.out / filename
|
||||||
path.write_text(BUILDERS[filename](), encoding="utf-8")
|
path.write_text(content, encoding="utf-8")
|
||||||
print(f"wrote {path.relative_to(ROOT)} ({path.stat().st_size:,} bytes)")
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Executable
+119
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Download the pinned browser libraries into the static vendor directory.
|
||||||
|
|
||||||
|
LLeMbas has no Node toolchain and loads nothing from a CDN at runtime -- a
|
||||||
|
self-hosted tool should keep working without internet access, and should not
|
||||||
|
report every user's page view to a third party. The three libraries it does use
|
||||||
|
are fetched once, here, and committed.
|
||||||
|
|
||||||
|
Integrity is enforced with vendor.lock.json. A mismatched hash aborts rather
|
||||||
|
than overwriting: that is the whole point of pinning.
|
||||||
|
|
||||||
|
python scripts/fetch_vendor.py # fetch and verify against the lock
|
||||||
|
python scripts/fetch_vendor.py --update # re-pin after a version bump
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
VENDOR_DIR = ROOT / "src" / "lembas" / "web" / "static" / "vendor"
|
||||||
|
LOCKFILE = Path(__file__).resolve().parent / "vendor.lock.json"
|
||||||
|
|
||||||
|
# Pinned deliberately. Bump the version, run with --update, review the diff.
|
||||||
|
PACKAGES = {
|
||||||
|
"htmx.min.js": {
|
||||||
|
"version": "2.0.10",
|
||||||
|
"url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js",
|
||||||
|
"why": "Server-rendered interactivity: every swap in the app.",
|
||||||
|
},
|
||||||
|
"htmx-ext-sse.js": {
|
||||||
|
"version": "2.2.4",
|
||||||
|
"url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js",
|
||||||
|
"why": "Server-sent events, which is how streamed replies reach the page.",
|
||||||
|
},
|
||||||
|
"alpine.min.js": {
|
||||||
|
"version": "3.15.12",
|
||||||
|
"url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js",
|
||||||
|
"why": "Small client-only state: menus, theme toggle, composer autosize.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url: str) -> bytes:
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": "lembas-vendor-fetch"})
|
||||||
|
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310
|
||||||
|
return response.read()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--update",
|
||||||
|
action="store_true",
|
||||||
|
help="rewrite vendor.lock.json with the hashes just downloaded",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
lock = json.loads(LOCKFILE.read_text()) if LOCKFILE.exists() else {}
|
||||||
|
VENDOR_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
new_lock: dict[str, dict[str, str]] = {}
|
||||||
|
failed = False
|
||||||
|
|
||||||
|
for filename, spec in PACKAGES.items():
|
||||||
|
try:
|
||||||
|
payload = fetch(spec["url"])
|
||||||
|
except (urllib.error.URLError, TimeoutError) as exc:
|
||||||
|
print(f" FAIL {filename}: {exc}", file=sys.stderr)
|
||||||
|
failed = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
digest = sha256(payload)
|
||||||
|
expected = lock.get(filename, {}).get("sha256")
|
||||||
|
|
||||||
|
if expected and digest != expected and not args.update:
|
||||||
|
print(
|
||||||
|
f" FAIL {filename}: hash mismatch\n"
|
||||||
|
f" expected {expected}\n"
|
||||||
|
f" received {digest}\n"
|
||||||
|
f" Refusing to overwrite. If the version was bumped "
|
||||||
|
f"deliberately, re-run with --update.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
failed = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
(VENDOR_DIR / filename).write_bytes(payload)
|
||||||
|
new_lock[filename] = {
|
||||||
|
"version": spec["version"],
|
||||||
|
"url": spec["url"],
|
||||||
|
"sha256": digest,
|
||||||
|
}
|
||||||
|
status = "ok" if expected == digest else ("pinned" if args.update else "new")
|
||||||
|
print(f" {status:>6} {filename} {len(payload):>8,} bytes v{spec['version']}")
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print("\nOne or more downloads failed. Vendored files were not fully written.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.update or not LOCKFILE.exists():
|
||||||
|
LOCKFILE.write_text(json.dumps(new_lock, indent=2, sort_keys=True) + "\n")
|
||||||
|
print(f"\nwrote {LOCKFILE.relative_to(ROOT)}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"alpine.min.js": {
|
||||||
|
"sha256": "57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f",
|
||||||
|
"url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js",
|
||||||
|
"version": "3.15.12"
|
||||||
|
},
|
||||||
|
"htmx-ext-sse.js": {
|
||||||
|
"sha256": "3b5992a541619babefc4c169505af474df5c3039da51e59b96ccf9241ecd61d2",
|
||||||
|
"url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js",
|
||||||
|
"version": "2.2.4"
|
||||||
|
},
|
||||||
|
"htmx.min.js": {
|
||||||
|
"sha256": "71ea67185bfa8c98c39d31717c6fce5d852370fcdfd129db4543774d3145c0de",
|
||||||
|
"url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js",
|
||||||
|
"version": "2.0.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Administration: OpenAI-compatible connections and their models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
|
from lembas.api.deps import AdminUser, Db
|
||||||
|
from lembas.db.models import Connection, Model
|
||||||
|
from lembas.services.crypto import decrypt, encrypt, mask
|
||||||
|
from lembas.services.llm.openai_client import Endpoint, LLMError, list_models
|
||||||
|
from lembas.web.templating import render
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
# Sent back in place of a stored key. If a submitted key still equals this, the
|
||||||
|
# admin did not touch the field and the existing key must be kept -- otherwise
|
||||||
|
# saving a name change would silently wipe the credential.
|
||||||
|
UNCHANGED_SENTINEL = "•" * 12
|
||||||
|
|
||||||
|
|
||||||
|
def _connection(db: DBSession, connection_id: str) -> Connection:
|
||||||
|
connection = db.get(Connection, connection_id)
|
||||||
|
if connection is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def _connections(db: DBSession) -> list[Connection]:
|
||||||
|
return list(db.scalars(select(Connection).order_by(Connection.position, Connection.name)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def admin_home(user: AdminUser):
|
||||||
|
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connections")
|
||||||
|
async def connections_page(request: Request, db: Db, user: AdminUser, message: str = ""):
|
||||||
|
connections = _connections(db)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"admin/connections.html",
|
||||||
|
{
|
||||||
|
"connections": connections,
|
||||||
|
"masked": {c.id: mask(decrypt(c.api_key_encrypted)) for c in connections},
|
||||||
|
"model_counts": {
|
||||||
|
c.id: sum(1 for m in c.models if m.enabled) for c in connections
|
||||||
|
},
|
||||||
|
"message": message,
|
||||||
|
"unchanged": UNCHANGED_SENTINEL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections")
|
||||||
|
async def create_connection(
|
||||||
|
db: Db,
|
||||||
|
user: AdminUser,
|
||||||
|
name: str = Form(...),
|
||||||
|
base_url: str = Form(...),
|
||||||
|
api_key: str = Form(""),
|
||||||
|
) -> Response:
|
||||||
|
base_url = base_url.strip().rstrip("/")
|
||||||
|
if not base_url.startswith(("http://", "https://")):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"The base URL must start with http:// or https://",
|
||||||
|
)
|
||||||
|
|
||||||
|
position = db.scalar(select(func.coalesce(func.max(Connection.position), -1))) + 1
|
||||||
|
connection = Connection(
|
||||||
|
name=name.strip()[:120] or "Connection",
|
||||||
|
base_url=base_url,
|
||||||
|
api_key_encrypted=encrypt(api_key.strip()),
|
||||||
|
position=position,
|
||||||
|
)
|
||||||
|
db.add(connection)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Discover models immediately: a connection that lists nothing is
|
||||||
|
# indistinguishable from a broken one, and finding out now is the point.
|
||||||
|
await _refresh_models(db, connection)
|
||||||
|
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/{connection_id}")
|
||||||
|
async def update_connection(
|
||||||
|
db: Db,
|
||||||
|
user: AdminUser,
|
||||||
|
connection_id: str,
|
||||||
|
name: str = Form(...),
|
||||||
|
base_url: str = Form(...),
|
||||||
|
api_key: str = Form(""),
|
||||||
|
enabled: bool = Form(False),
|
||||||
|
) -> Response:
|
||||||
|
connection = _connection(db, connection_id)
|
||||||
|
connection.name = name.strip()[:120] or connection.name
|
||||||
|
connection.base_url = base_url.strip().rstrip("/")
|
||||||
|
connection.enabled = enabled
|
||||||
|
|
||||||
|
submitted = api_key.strip()
|
||||||
|
if submitted and submitted != UNCHANGED_SENTINEL:
|
||||||
|
connection.api_key_encrypted = encrypt(submitted)
|
||||||
|
elif not submitted:
|
||||||
|
# An explicitly emptied field means "this endpoint needs no key".
|
||||||
|
connection.api_key_encrypted = ""
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/{connection_id}/test")
|
||||||
|
async def test_connection(
|
||||||
|
request: Request, db: Db, user: AdminUser, connection_id: str
|
||||||
|
) -> Response:
|
||||||
|
"""Contact the endpoint and refresh its model list."""
|
||||||
|
connection = _connection(db, connection_id)
|
||||||
|
count, error = await _refresh_models(db, connection)
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f"{connection.name}: {error}"
|
||||||
|
if error
|
||||||
|
else f"{connection.name}: found {count} model{'s' if count != 1 else ''}."
|
||||||
|
)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"admin/_connection_row.html",
|
||||||
|
{
|
||||||
|
"connection": connection,
|
||||||
|
"masked": mask(decrypt(connection.api_key_encrypted)),
|
||||||
|
"message": message,
|
||||||
|
"message_kind": "error" if error else "success",
|
||||||
|
"unchanged": UNCHANGED_SENTINEL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_models(db: DBSession, connection: Connection) -> tuple[int, str]:
|
||||||
|
"""Sync the cached model list. Returns (count, error message)."""
|
||||||
|
try:
|
||||||
|
discovered = await list_models(Endpoint.from_connection(connection))
|
||||||
|
except LLMError as exc:
|
||||||
|
connection.last_error = exc.message
|
||||||
|
connection.last_checked_at = datetime.now(UTC)
|
||||||
|
db.commit()
|
||||||
|
return 0, exc.message
|
||||||
|
|
||||||
|
existing = {model.model_id: model for model in connection.models}
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for entry in discovered:
|
||||||
|
model_id = str(entry["id"])[:300]
|
||||||
|
seen.add(model_id)
|
||||||
|
if model_id in existing:
|
||||||
|
continue
|
||||||
|
db.add(Model(connection_id=connection.id, model_id=model_id))
|
||||||
|
|
||||||
|
# Models that vanished upstream are dropped, so the picker never offers
|
||||||
|
# something the endpoint will reject.
|
||||||
|
for model_id, model in existing.items():
|
||||||
|
if model_id not in seen:
|
||||||
|
db.delete(model)
|
||||||
|
|
||||||
|
connection.last_error = ""
|
||||||
|
connection.last_checked_at = datetime.now(UTC)
|
||||||
|
db.commit()
|
||||||
|
log.info("connection %s: %d models", connection.name, len(seen))
|
||||||
|
return len(seen), ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/{connection_id}/delete")
|
||||||
|
async def delete_connection(db: Db, user: AdminUser, connection_id: str) -> Response:
|
||||||
|
connection = _connection(db, connection_id)
|
||||||
|
db.delete(connection)
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models")
|
||||||
|
async def models_page(request: Request, db: Db, user: AdminUser):
|
||||||
|
connections = _connections(db)
|
||||||
|
return render(request, "admin/models.html", {"connections": connections})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/models/{model_id}/toggle")
|
||||||
|
async def toggle_model(db: Db, user: AdminUser, model_id: str) -> Response:
|
||||||
|
model = db.get(Model, model_id)
|
||||||
|
if model is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")
|
||||||
|
model.enabled = not model.enabled
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/admin/models", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Registration, sign-in and sign-out."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, Request, Response, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from lembas.api.deps import CurrentUser, Db
|
||||||
|
from lembas.config import settings
|
||||||
|
from lembas.db.models import ROLE_ADMIN, ROLE_USER, User
|
||||||
|
from lembas.security.passwords import hash_password, validate_password, verify_password
|
||||||
|
from lembas.security.sessions import COOKIE_NAME, create_session, revoke_session
|
||||||
|
from lembas.web.templating import render
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
def _no_users_yet(db: Db) -> bool:
|
||||||
|
return db.scalar(select(func.count()).select_from(User)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _set_session_cookie(response: Response, token: str) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
COOKIE_NAME,
|
||||||
|
token,
|
||||||
|
max_age=settings.session_ttl,
|
||||||
|
httponly=True,
|
||||||
|
# Lax is what makes this application CSRF-safe without tokens: the
|
||||||
|
# cookie is not sent on cross-site POSTs, and every mutating route here
|
||||||
|
# is a POST. Do not relax to "none".
|
||||||
|
samesite="lax",
|
||||||
|
# Only over HTTPS when the deployment is not plain local http. Marking
|
||||||
|
# it secure on http would silently break sign-in for a LAN install.
|
||||||
|
secure=False,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_next(raw: str | None) -> str:
|
||||||
|
"""Reject open redirects: only same-origin absolute paths are allowed."""
|
||||||
|
if not raw or not raw.startswith("/") or raw.startswith("//"):
|
||||||
|
return "/"
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login")
|
||||||
|
async def login_form(request: Request, db: Db, user: CurrentUser, next: str = "/"):
|
||||||
|
if user is not None:
|
||||||
|
return RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
# An empty database means this install has never been set up. Send the
|
||||||
|
# first visitor straight to registration rather than to a login form they
|
||||||
|
# cannot possibly satisfy.
|
||||||
|
if _no_users_yet(db):
|
||||||
|
return RedirectResponse("/auth/register", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
return render(request, "auth/login.html", {"next": _safe_next(next)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(
|
||||||
|
request: Request,
|
||||||
|
db: Db,
|
||||||
|
email: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
next: str = Form("/"),
|
||||||
|
):
|
||||||
|
email = email.strip().lower()
|
||||||
|
user = db.scalar(select(User).where(User.email == email))
|
||||||
|
|
||||||
|
# One message for "no such account" and "wrong password" alike, so the form
|
||||||
|
# cannot be used to discover which addresses are registered.
|
||||||
|
if user is None or not verify_password(password, user.password_hash):
|
||||||
|
log.info("failed sign-in for %s", email)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"auth/login.html",
|
||||||
|
{"error": "That email and password do not match.", "email": email,
|
||||||
|
"next": _safe_next(next)},
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.active:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"auth/login.html",
|
||||||
|
{"error": "This account has been deactivated. Ask an administrator.",
|
||||||
|
"email": email, "next": _safe_next(next)},
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
token = create_session(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
user_agent=request.headers.get("user-agent", ""),
|
||||||
|
ip_address=request.client.host if request.client else "",
|
||||||
|
)
|
||||||
|
response = RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
_set_session_cookie(response, token)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/register")
|
||||||
|
async def register_form(request: Request, db: Db, user: CurrentUser):
|
||||||
|
if user is not None:
|
||||||
|
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
first_run = _no_users_yet(db)
|
||||||
|
if not first_run and not settings.allow_signup:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"auth/login.html",
|
||||||
|
{"error": "Registration is closed. Ask an administrator for an account."},
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
return render(request, "auth/register.html", {"first_run": first_run})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register")
|
||||||
|
async def register(
|
||||||
|
request: Request,
|
||||||
|
db: Db,
|
||||||
|
name: str = Form(...),
|
||||||
|
email: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
):
|
||||||
|
first_run = _no_users_yet(db)
|
||||||
|
if not first_run and not settings.allow_signup:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"auth/login.html",
|
||||||
|
{"error": "Registration is closed. Ask an administrator for an account."},
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
name = name.strip()
|
||||||
|
email = email.strip().lower()
|
||||||
|
|
||||||
|
def fail(message: str) -> Response:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"auth/register.html",
|
||||||
|
{"error": message, "name": name, "email": email, "first_run": first_run},
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return fail("Please enter a name.")
|
||||||
|
if "@" not in email or "." not in email.split("@")[-1]:
|
||||||
|
return fail("Please enter a valid email address.")
|
||||||
|
if (problem := validate_password(password)) is not None:
|
||||||
|
return fail(problem)
|
||||||
|
if db.scalar(select(User).where(User.email == email)) is not None:
|
||||||
|
return fail("An account with that email already exists.")
|
||||||
|
|
||||||
|
# Whoever sets the instance up owns it. Everyone after that is a plain user
|
||||||
|
# until an admin says otherwise.
|
||||||
|
user = User(
|
||||||
|
name=name,
|
||||||
|
email=email,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=ROLE_ADMIN if first_run else ROLE_USER,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
log.info("registered %s as %s", email, user.role)
|
||||||
|
|
||||||
|
token = create_session(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
user_agent=request.headers.get("user-agent", ""),
|
||||||
|
ip_address=request.client.host if request.client else "",
|
||||||
|
)
|
||||||
|
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
_set_session_cookie(response, token)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(request: Request, db: Db):
|
||||||
|
revoke_session(db, request.cookies.get(COOKIE_NAME))
|
||||||
|
response = RedirectResponse("/auth/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
response.delete_cookie(COOKIE_NAME, path="/")
|
||||||
|
return response
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""Chat creation, messaging and the streaming reply endpoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, HTTPException, Request, status
|
||||||
|
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
|
from lembas.api.deps import Db, RequiredUser
|
||||||
|
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
|
||||||
|
from lembas.db.session import session_scope
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
from lembas.services import sse
|
||||||
|
from lembas.services.llm.openai_client import LLMError, delta_text, stream_chat
|
||||||
|
from lembas.services.markdown import escape_text, render_markdown
|
||||||
|
from lembas.web.templating import render, templates
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/chats", tags=["chats"])
|
||||||
|
|
||||||
|
|
||||||
|
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
||||||
|
chat = db.get(Chat, chat_id)
|
||||||
|
# 404 rather than 403 for someone else's chat: whether a given id exists is
|
||||||
|
# not information this endpoint should hand out.
|
||||||
|
if chat is None or chat.user_id != user_id:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
||||||
|
return chat
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_chat(db: Db, user: RequiredUser, folder_id: str = Form("")) -> Response:
|
||||||
|
chosen = chat_service.default_model(db)
|
||||||
|
chat = Chat(
|
||||||
|
user_id=user.id,
|
||||||
|
folder_id=folder_id or None,
|
||||||
|
model_id=chosen[0] if chosen else "",
|
||||||
|
connection_id=chosen[1] if chosen else None,
|
||||||
|
)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# HX-Redirect rather than a swap: a new chat is a new URL, and the address
|
||||||
|
# bar has to follow so the chat can be reloaded or bookmarked.
|
||||||
|
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
response.headers["HX-Redirect"] = f"/chat/{chat.id}"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{chat_id}/messages")
|
||||||
|
async def post_message(
|
||||||
|
request: Request,
|
||||||
|
db: Db,
|
||||||
|
user: RequiredUser,
|
||||||
|
chat_id: str,
|
||||||
|
content: str = Form(...),
|
||||||
|
) -> Response:
|
||||||
|
"""Persist the user's turn and hand back the pair of bubbles.
|
||||||
|
|
||||||
|
The assistant bubble comes back empty, carrying the sse-connect attribute
|
||||||
|
that opens the stream below. Splitting it this way means the POST returns
|
||||||
|
immediately and the slow part is a separate, resumable connection.
|
||||||
|
"""
|
||||||
|
chat = _owned_chat(db, chat_id, user.id)
|
||||||
|
|
||||||
|
content = content.strip()
|
||||||
|
if not content:
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
||||||
|
assistant_message = chat_service.create_message(
|
||||||
|
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# `user` is required by the shared message template, which renders both
|
||||||
|
# roles; without it the user bubble's initial blows up.
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"chat/_turn.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user_message": user_message,
|
||||||
|
"assistant_message": assistant_message,
|
||||||
|
"chat": chat,
|
||||||
|
"user": user,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{chat_id}/messages/{message_id}/stream")
|
||||||
|
async def stream_message(
|
||||||
|
db: Db,
|
||||||
|
user: RequiredUser,
|
||||||
|
chat_id: str,
|
||||||
|
message_id: str,
|
||||||
|
) -> Response:
|
||||||
|
"""Stream the assistant's reply as server-sent events.
|
||||||
|
|
||||||
|
Emits `token` events carrying escaped text, then a single `done` event
|
||||||
|
carrying the finished bubble rendered from Markdown, then `close`.
|
||||||
|
"""
|
||||||
|
chat = _owned_chat(db, chat_id, user.id)
|
||||||
|
message = db.get(Message, message_id)
|
||||||
|
if message is None or message.chat_id != chat.id:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_generate(chat.id, message.id),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
# nginx buffers proxied responses by default, which turns a stream
|
||||||
|
# into one delivery at the end. This is the documented opt-out.
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
||||||
|
"""Drive one completion and frame it as SSE.
|
||||||
|
|
||||||
|
Opens its own database session rather than using the request's: streaming
|
||||||
|
outlives the request handler, and the dependency-scoped session may already
|
||||||
|
be closed by the time the first token arrives.
|
||||||
|
"""
|
||||||
|
accumulated: list[str] = []
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
chat = db.get(Chat, chat_id)
|
||||||
|
message = db.get(Message, message_id)
|
||||||
|
if chat is None or message is None:
|
||||||
|
yield sse.event("close", "")
|
||||||
|
return
|
||||||
|
|
||||||
|
first_user_text = ""
|
||||||
|
try:
|
||||||
|
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
|
||||||
|
payload = chat_service.build_request(db, chat, upto=message)
|
||||||
|
first_user_text = next(
|
||||||
|
(m["content"] for m in reversed(payload["messages"]) if m["role"] == ROLE_USER),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
async for chunk in stream_chat(endpoint, payload):
|
||||||
|
text = delta_text(chunk)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
accumulated.append(text)
|
||||||
|
yield sse.event("token", escape_text(text))
|
||||||
|
# Hand control back so the event is flushed rather than
|
||||||
|
# batched behind a fast generator.
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
except LLMError as exc:
|
||||||
|
error = exc.message
|
||||||
|
log.info("generation failed for chat %s: %s", chat_id, exc.message)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# The reader navigated away or closed the tab. Keep whatever was
|
||||||
|
# produced so the partial reply is still there on reload.
|
||||||
|
message.content = "".join(accumulated)
|
||||||
|
message.complete = True
|
||||||
|
db.commit()
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001 - must not kill the stream silently
|
||||||
|
error = "Something went wrong while generating this reply."
|
||||||
|
log.exception("unexpected generation failure for chat %s: %s", chat_id, exc)
|
||||||
|
|
||||||
|
message.content = "".join(accumulated)
|
||||||
|
message.error = error or ""
|
||||||
|
message.complete = True
|
||||||
|
|
||||||
|
if not chat.title_generated and (accumulated or error):
|
||||||
|
chat.title = (
|
||||||
|
await chat_service.generate_title(
|
||||||
|
endpoint, model_id, first_user_text, message.content
|
||||||
|
)
|
||||||
|
if not error and first_user_text
|
||||||
|
else chat_service.fallback_title(first_user_text)
|
||||||
|
)
|
||||||
|
chat.title_generated = True
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
final_html = templates.get_template("chat/_message.html").render(
|
||||||
|
{
|
||||||
|
"message": message,
|
||||||
|
"body_html": render_markdown(message.content),
|
||||||
|
"chat": chat,
|
||||||
|
# Passed even though an assistant bubble never reads it: the
|
||||||
|
# template shares both roles, and a missing `user` would only
|
||||||
|
# blow up on whichever branch is not being exercised here.
|
||||||
|
"user": db.get(User, chat.user_id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
title_html = templates.get_template("chat/_title_oob.html").render(
|
||||||
|
{"chat": chat}
|
||||||
|
)
|
||||||
|
|
||||||
|
yield sse.event("done", final_html + title_html)
|
||||||
|
yield sse.event("close", "")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{chat_id}")
|
||||||
|
async def update_chat(
|
||||||
|
db: Db,
|
||||||
|
user: RequiredUser,
|
||||||
|
chat_id: str,
|
||||||
|
title: str | None = Form(None),
|
||||||
|
folder_id: str | None = Form(None),
|
||||||
|
model_id: str | None = Form(None),
|
||||||
|
) -> Response:
|
||||||
|
chat = _owned_chat(db, chat_id, user.id)
|
||||||
|
|
||||||
|
if title is not None:
|
||||||
|
cleaned = title.strip()[:300]
|
||||||
|
if cleaned:
|
||||||
|
chat.title = cleaned
|
||||||
|
# An explicit rename must not be overwritten by auto-titling later.
|
||||||
|
chat.title_generated = True
|
||||||
|
|
||||||
|
if folder_id is not None:
|
||||||
|
chat.folder_id = folder_id or None
|
||||||
|
|
||||||
|
if model_id is not None and model_id:
|
||||||
|
chat.model_id = model_id
|
||||||
|
match = next(
|
||||||
|
(m for m in chat_service.available_models(db) if m.model_id == model_id), None
|
||||||
|
)
|
||||||
|
chat.connection_id = match.connection_id if match else None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{chat_id}")
|
||||||
|
async def delete_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||||
|
chat = _owned_chat(db, chat_id, user.id)
|
||||||
|
db.delete(chat)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
response.headers["HX-Redirect"] = "/chat"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{chat_id}/messages/{message_id}/raw")
|
||||||
|
async def raw_message(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> HTMLResponse:
|
||||||
|
"""The unrendered Markdown of a message, for the copy button."""
|
||||||
|
_owned_chat(db, chat_id, user.id)
|
||||||
|
message = db.get(Message, message_id)
|
||||||
|
if message is None or message.chat_id != chat_id:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
||||||
|
return HTMLResponse(escape_text(message.content))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{chat_id}/messages/{message_id}/regenerate")
|
||||||
|
async def regenerate(
|
||||||
|
request: Request,
|
||||||
|
db: Db,
|
||||||
|
user: RequiredUser,
|
||||||
|
chat_id: str,
|
||||||
|
message_id: str,
|
||||||
|
) -> Response:
|
||||||
|
"""Discard an assistant reply and produce a fresh one in its place."""
|
||||||
|
chat = _owned_chat(db, chat_id, user.id)
|
||||||
|
message = db.get(Message, message_id)
|
||||||
|
if message is None or message.chat_id != chat.id or message.role != ROLE_ASSISTANT:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That reply no longer exists.")
|
||||||
|
|
||||||
|
message.content = ""
|
||||||
|
message.error = ""
|
||||||
|
message.complete = False
|
||||||
|
message.model_id = chat.model_id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"chat/_message.html",
|
||||||
|
{"request": request, "message": message, "chat": chat, "body_html": "", "user": user},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["render", "router"]
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Folder management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, HTTPException, Response, status
|
||||||
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
|
from lembas.api.deps import Db, RequiredUser
|
||||||
|
from lembas.db.models import Folder
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/folders", tags=["folders"])
|
||||||
|
|
||||||
|
MAX_DEPTH = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _owned_folder(db: DBSession, folder_id: str, user_id: str) -> Folder:
|
||||||
|
folder = db.get(Folder, folder_id)
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That folder no longer exists.")
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
def _depth_of(db: DBSession, folder: Folder | None) -> int:
|
||||||
|
depth = 0
|
||||||
|
seen: set[str] = set()
|
||||||
|
while folder is not None and folder.id not in seen:
|
||||||
|
seen.add(folder.id)
|
||||||
|
depth += 1
|
||||||
|
folder = db.get(Folder, folder.parent_id) if folder.parent_id else None
|
||||||
|
return depth
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_sidebar() -> Response:
|
||||||
|
"""Tell the browser to reload so the tree re-renders.
|
||||||
|
|
||||||
|
The folder tree is recursive and a change can move any part of it, so
|
||||||
|
re-rendering the whole sidebar server-side is both simpler and less
|
||||||
|
error-prone than trying to patch individual nodes over the wire.
|
||||||
|
"""
|
||||||
|
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
response.headers["HX-Refresh"] = "true"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_folder(
|
||||||
|
db: Db,
|
||||||
|
user: RequiredUser,
|
||||||
|
name: str = Form("New folder"),
|
||||||
|
parent_id: str = Form(""),
|
||||||
|
) -> Response:
|
||||||
|
parent = _owned_folder(db, parent_id, user.id) if parent_id else None
|
||||||
|
|
||||||
|
# A cap on nesting, so a runaway client cannot build a tree deep enough to
|
||||||
|
# blow the recursion limit in the template.
|
||||||
|
if parent is not None and _depth_of(db, parent) >= MAX_DEPTH:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
f"Folders cannot be nested more than {MAX_DEPTH} deep.",
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
Folder(
|
||||||
|
user_id=user.id,
|
||||||
|
name=name.strip()[:200] or "New folder",
|
||||||
|
parent_id=parent.id if parent else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _refresh_sidebar()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{folder_id}")
|
||||||
|
async def update_folder(
|
||||||
|
db: Db,
|
||||||
|
user: RequiredUser,
|
||||||
|
folder_id: str,
|
||||||
|
name: str | None = Form(None),
|
||||||
|
parent_id: str | None = Form(None),
|
||||||
|
collapsed: bool | None = Form(None),
|
||||||
|
) -> Response:
|
||||||
|
folder = _owned_folder(db, folder_id, user.id)
|
||||||
|
|
||||||
|
if name is not None and name.strip():
|
||||||
|
folder.name = name.strip()[:200]
|
||||||
|
|
||||||
|
if parent_id is not None:
|
||||||
|
new_parent = _owned_folder(db, parent_id, user.id) if parent_id else None
|
||||||
|
# Reparenting a folder into its own subtree would detach that subtree
|
||||||
|
# from the root and make it unreachable.
|
||||||
|
cursor = new_parent
|
||||||
|
while cursor is not None:
|
||||||
|
if cursor.id == folder.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"A folder cannot be moved inside itself.",
|
||||||
|
)
|
||||||
|
cursor = db.get(Folder, cursor.parent_id) if cursor.parent_id else None
|
||||||
|
folder.parent_id = new_parent.id if new_parent else None
|
||||||
|
|
||||||
|
if collapsed is not None:
|
||||||
|
folder.collapsed = collapsed
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return _refresh_sidebar()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{folder_id}")
|
||||||
|
async def delete_folder(db: Db, user: RequiredUser, folder_id: str) -> Response:
|
||||||
|
"""Delete a folder. Child folders go with it; chats do not.
|
||||||
|
|
||||||
|
Chats fall back to the unfiled list (the FK is ON DELETE SET NULL), because
|
||||||
|
losing a conversation to a mis-clicked folder delete is unforgivable.
|
||||||
|
"""
|
||||||
|
folder = _owned_folder(db, folder_id, user.id)
|
||||||
|
db.delete(folder)
|
||||||
|
db.commit()
|
||||||
|
return _refresh_sidebar()
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Full-page routes: the chat shell and the user's own settings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
|
from lembas.api.deps import Db, RequiredUser
|
||||||
|
from lembas.db.models import Chat, Folder, Message, User
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
from lembas.services.markdown import render_markdown
|
||||||
|
from lembas.web.templating import render
|
||||||
|
|
||||||
|
router = APIRouter(tags=["pages"])
|
||||||
|
|
||||||
|
|
||||||
|
def _sidebar_context(db: DBSession, user: User) -> dict:
|
||||||
|
"""Folder tree plus the chats that belong to no folder.
|
||||||
|
|
||||||
|
Only root folders are queried; children come through the relationship and
|
||||||
|
render recursively in the template.
|
||||||
|
"""
|
||||||
|
folders = list(
|
||||||
|
db.scalars(
|
||||||
|
select(Folder)
|
||||||
|
.where(Folder.user_id == user.id, Folder.parent_id.is_(None))
|
||||||
|
.order_by(Folder.position, Folder.name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unfiled = list(
|
||||||
|
db.scalars(
|
||||||
|
select(Chat)
|
||||||
|
.where(
|
||||||
|
Chat.user_id == user.id,
|
||||||
|
Chat.folder_id.is_(None),
|
||||||
|
Chat.archived.is_(False),
|
||||||
|
)
|
||||||
|
.order_by(Chat.pinned.desc(), Chat.updated_at.desc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {"folders": folders, "unfiled_chats": unfiled}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def home(user: RequiredUser):
|
||||||
|
return RedirectResponse("/chat", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chat")
|
||||||
|
async def chat_index(request: Request, db: Db, user: RequiredUser):
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"chat/index.html",
|
||||||
|
{
|
||||||
|
"chat": None,
|
||||||
|
"messages": [],
|
||||||
|
"models": chat_service.available_models(db),
|
||||||
|
**_sidebar_context(db, user),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chat/{chat_id}")
|
||||||
|
async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str):
|
||||||
|
chat = db.get(Chat, chat_id)
|
||||||
|
if chat is None or chat.user_id != user.id:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
||||||
|
|
||||||
|
messages = list(
|
||||||
|
db.scalars(
|
||||||
|
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Markdown is rendered once here rather than in the template so the same
|
||||||
|
# helper produces the page and the streamed final frame -- one code path,
|
||||||
|
# no chance of the two disagreeing.
|
||||||
|
bodies = {
|
||||||
|
message.id: render_markdown(message.content)
|
||||||
|
for message in messages
|
||||||
|
if message.role == "assistant" and message.content
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"chat/index.html",
|
||||||
|
{
|
||||||
|
"chat": chat,
|
||||||
|
"messages": messages,
|
||||||
|
"bodies": bodies,
|
||||||
|
"models": chat_service.available_models(db),
|
||||||
|
**_sidebar_context(db, user),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
async def settings_page(request: Request, db: Db, user: RequiredUser):
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"settings.html",
|
||||||
|
{
|
||||||
|
"chat": None,
|
||||||
|
"models": chat_service.available_models(db),
|
||||||
|
**_sidebar_context(db, user),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Per-user preferences set from the browser."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body
|
||||||
|
|
||||||
|
from lembas.api.deps import Db, RequiredUser
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/preferences", tags=["preferences"])
|
||||||
|
|
||||||
|
THEMES = ("moria", "shire")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/theme")
|
||||||
|
async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=True)) -> dict:
|
||||||
|
"""Mirror the browser's theme choice onto the account.
|
||||||
|
|
||||||
|
localStorage is the source of truth for the current tab; this is what makes
|
||||||
|
the choice follow the user to another browser, and what lets the server
|
||||||
|
render the right theme on first paint instead of flashing the default.
|
||||||
|
"""
|
||||||
|
if theme not in THEMES:
|
||||||
|
return {"ok": False, "detail": "Unknown theme."}
|
||||||
|
|
||||||
|
# Replaced rather than mutated in place: SQLAlchemy only reliably detects
|
||||||
|
# a change to a JSON column when the whole value is reassigned.
|
||||||
|
user.settings_json = {**(user.settings_json or {}), "theme": theme}
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "theme": theme}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Command line entry points."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets as secrets_module
|
||||||
|
|
||||||
|
import typer
|
||||||
|
import uvicorn
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from lembas import __version__
|
||||||
|
from lembas.config import settings
|
||||||
|
|
||||||
|
app = typer.Typer(
|
||||||
|
help="LLeMbas - a Middle-earth themed web UI for your language models.",
|
||||||
|
no_args_is_help=True,
|
||||||
|
add_completion=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def serve(
|
||||||
|
host: str = typer.Option(None, help="Bind address. Defaults to LEMBAS_HOST."),
|
||||||
|
port: int = typer.Option(None, help="Port. Defaults to LEMBAS_PORT."),
|
||||||
|
reload: bool = typer.Option(None, "--reload/--no-reload", help="Autoreload on change."),
|
||||||
|
) -> None:
|
||||||
|
"""Run the web server."""
|
||||||
|
uvicorn.run(
|
||||||
|
"lembas.main:app",
|
||||||
|
host=host or settings.host,
|
||||||
|
port=port or settings.port,
|
||||||
|
reload=settings.reload if reload is None else reload,
|
||||||
|
log_level=settings.log_level,
|
||||||
|
# Access logs duplicate what the application already logs and drown out
|
||||||
|
# anything useful during development.
|
||||||
|
access_log=settings.log_level == "debug",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("create-admin")
|
||||||
|
def create_admin(
|
||||||
|
email: str = typer.Option(..., prompt=True),
|
||||||
|
name: str = typer.Option(..., prompt=True),
|
||||||
|
password: str = typer.Option(..., prompt=True, hide_input=True, confirmation_prompt=True),
|
||||||
|
) -> None:
|
||||||
|
"""Create an administrator, or promote an existing account to one.
|
||||||
|
|
||||||
|
The web sign-up already makes the first account an admin. This is the way
|
||||||
|
back in when that account is lost, or when scripting a deployment.
|
||||||
|
"""
|
||||||
|
from lembas.db.models import ROLE_ADMIN, User
|
||||||
|
from lembas.db.session import init_db, session_scope
|
||||||
|
from lembas.security.passwords import hash_password, validate_password
|
||||||
|
|
||||||
|
if (problem := validate_password(password)) is not None:
|
||||||
|
typer.secho(problem, fg=typer.colors.RED)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
with session_scope() as db:
|
||||||
|
existing = db.scalar(select(User).where(User.email == email.strip().lower()))
|
||||||
|
if existing is not None:
|
||||||
|
existing.role = ROLE_ADMIN
|
||||||
|
existing.password_hash = hash_password(password)
|
||||||
|
existing.active = True
|
||||||
|
typer.secho(f"Promoted {existing.email} to administrator.", fg=typer.colors.GREEN)
|
||||||
|
return
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
User(
|
||||||
|
email=email.strip().lower(),
|
||||||
|
name=name.strip(),
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=ROLE_ADMIN,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
typer.secho(f"Created administrator {email}.", fg=typer.colors.GREEN)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("secret-key")
|
||||||
|
def secret_key() -> None:
|
||||||
|
"""Print a fresh value for LEMBAS_SECRET_KEY."""
|
||||||
|
typer.echo(secrets_module.token_urlsafe(48))
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def info() -> None:
|
||||||
|
"""Show where this instance keeps its data and what is configured."""
|
||||||
|
from lembas.db.models import Chat, Connection, User
|
||||||
|
from lembas.db.session import init_db, session_scope
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
typer.echo(f"LLeMbas {__version__}")
|
||||||
|
typer.echo(f" data directory : {settings.data_dir.resolve()}")
|
||||||
|
typer.echo(f" database : {settings.db_path.resolve()}")
|
||||||
|
typer.echo(f" bind : {settings.host}:{settings.port}")
|
||||||
|
typer.echo(f" default theme : {settings.default_theme}")
|
||||||
|
typer.echo(f" signup open : {settings.allow_signup}")
|
||||||
|
if settings.secret_key_is_ephemeral:
|
||||||
|
typer.secho(
|
||||||
|
" secret key : GENERATED (set LEMBAS_SECRET_KEY for a real install)",
|
||||||
|
fg=typer.colors.YELLOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
for label, model in (("users", User), ("connections", Connection), ("chats", Chat)):
|
||||||
|
count = db.scalar(select(func.count()).select_from(model))
|
||||||
|
typer.echo(f" {label:<15}: {count}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
+14
-8
@@ -7,7 +7,7 @@ from functools import lru_cache
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import Field, field_validator
|
from pydantic import Field, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +22,10 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
secret_key: str = Field(default="")
|
secret_key: str = Field(default="")
|
||||||
|
# Set when no LEMBAS_SECRET_KEY was supplied and one had to be invented.
|
||||||
|
# main.py warns about it at startup; see the validator below.
|
||||||
|
secret_key_is_ephemeral: bool = Field(default=False, exclude=True)
|
||||||
|
|
||||||
data_dir: Path = Path("./data")
|
data_dir: Path = Path("./data")
|
||||||
|
|
||||||
host: str = "127.0.0.1"
|
host: str = "127.0.0.1"
|
||||||
@@ -34,13 +38,15 @@ class Settings(BaseSettings):
|
|||||||
session_ttl: int = 60 * 60 * 24 * 30
|
session_ttl: int = 60 * 60 * 24 * 30
|
||||||
request_timeout: float = 300.0
|
request_timeout: float = 300.0
|
||||||
|
|
||||||
@field_validator("secret_key")
|
@model_validator(mode="after")
|
||||||
@classmethod
|
def _generate_secret_if_absent(self) -> Settings:
|
||||||
def _generate_secret_if_absent(cls, v: str) -> str:
|
# A generated key lets `lembas serve` work with no configuration at all,
|
||||||
# A generated key lets `lembas serve` work out of the box, but it changes
|
# but it changes on every restart: sessions drop and stored API keys
|
||||||
# on every restart: sessions drop and stored API keys become unreadable.
|
# become unreadable. Flagged so startup can warn. Never use in anger.
|
||||||
# main.py warns loudly about this. Never rely on it in production.
|
if not self.secret_key:
|
||||||
return v or secrets.token_urlsafe(48)
|
self.secret_key = secrets.token_urlsafe(48)
|
||||||
|
self.secret_key_is_ephemeral = True
|
||||||
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def db_path(self) -> Path:
|
def db_path(self) -> Path:
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Application factory, lifespan and error handling."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, status
|
||||||
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
|
from lembas import __version__
|
||||||
|
from lembas.api import admin, auth, chats, folders, pages, preferences
|
||||||
|
from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect
|
||||||
|
from lembas.config import settings
|
||||||
|
from lembas.db.session import init_db
|
||||||
|
from lembas.web.templating import STATIC_DIR, render
|
||||||
|
|
||||||
|
log = logging.getLogger("lembas")
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=settings.log_level.upper(),
|
||||||
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
configure_logging()
|
||||||
|
settings.ensure_dirs()
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
if settings.secret_key_is_ephemeral:
|
||||||
|
log.warning(
|
||||||
|
"No LEMBAS_SECRET_KEY set, so a temporary one was generated. Every "
|
||||||
|
"restart will sign all users out and make stored API keys "
|
||||||
|
"unreadable. Generate a permanent key with:\n"
|
||||||
|
' python -c "import secrets; print(secrets.token_urlsafe(48))"'
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("LLeMbas %s starting on http://%s:%s", __version__, settings.host, settings.port)
|
||||||
|
log.info("data directory: %s", settings.data_dir.resolve())
|
||||||
|
yield
|
||||||
|
log.info("LLeMbas stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(
|
||||||
|
title="LLeMbas",
|
||||||
|
version=__version__,
|
||||||
|
lifespan=lifespan,
|
||||||
|
# The API is an implementation detail of the UI, not a product surface.
|
||||||
|
docs_url="/api/docs" if settings.log_level == "debug" else None,
|
||||||
|
redoc_url=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
app.include_router(pages.router)
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(preferences.router)
|
||||||
|
app.include_router(chats.router)
|
||||||
|
app.include_router(folders.router)
|
||||||
|
app.include_router(admin.router)
|
||||||
|
|
||||||
|
register_error_handlers(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def register_error_handlers(app: FastAPI) -> None:
|
||||||
|
@app.exception_handler(RedirectToLogin)
|
||||||
|
async def _not_signed_in(request: Request, exc: RedirectToLogin) -> Response:
|
||||||
|
# An htmx request must not swap a login page into a fragment of the
|
||||||
|
# chat UI, so tell the browser to navigate instead.
|
||||||
|
if is_htmx(request):
|
||||||
|
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
response.headers["HX-Redirect"] = "/auth/login"
|
||||||
|
return response
|
||||||
|
return login_redirect(exc.next_url)
|
||||||
|
|
||||||
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
async def _http_error(request: Request, exc: StarletteHTTPException) -> Response:
|
||||||
|
# JSON callers and htmx fragments want the bare status; humans loading a
|
||||||
|
# page want a themed page they can navigate away from.
|
||||||
|
wants_page = "text/html" in request.headers.get("accept", "") and not is_htmx(request)
|
||||||
|
if not wants_page:
|
||||||
|
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"error.html",
|
||||||
|
{
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
"detail": exc.detail,
|
||||||
|
"flavour": ERROR_FLAVOUR.get(exc.status_code, ERROR_FLAVOUR[500]),
|
||||||
|
},
|
||||||
|
status_code=exc.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def _unhandled(request: Request, exc: Exception) -> Response:
|
||||||
|
log.exception("unhandled error at %s", request.url.path)
|
||||||
|
if is_htmx(request) or "text/html" not in request.headers.get("accept", ""):
|
||||||
|
return JSONResponse({"detail": "Internal server error"}, status_code=500)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"error.html",
|
||||||
|
{"status_code": 500, "detail": "Something went wrong.",
|
||||||
|
"flavour": ERROR_FLAVOUR[500]},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Flavour lives in error pages, empty states and theme names -- never in the
|
||||||
|
# functional UI. See CLAUDE.md.
|
||||||
|
ERROR_FLAVOUR = {
|
||||||
|
403: "Speak, friend, and enter. This door is not yours to open.",
|
||||||
|
404: "Not all those who wander are lost. This page, however, is.",
|
||||||
|
500: "The Road goes ever on, but this stretch of it has washed out.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -8,7 +8,7 @@ defaults tighten in a future release.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import InvalidHashError, VerifyMismatchError, VerificationError
|
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
||||||
|
|
||||||
_hasher = PasswordHasher()
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Chat orchestration: building requests, streaming replies, naming chats."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
|
from lembas.db.models import (
|
||||||
|
ROLE_ASSISTANT,
|
||||||
|
ROLE_SYSTEM,
|
||||||
|
ROLE_USER,
|
||||||
|
Chat,
|
||||||
|
Connection,
|
||||||
|
Message,
|
||||||
|
Model,
|
||||||
|
)
|
||||||
|
from lembas.services.llm.openai_client import Endpoint, LLMError, complete
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Sampling keys forwarded upstream. Anything else a user puts in params_json is
|
||||||
|
# ignored rather than passed through, so a typo cannot produce a 400 from the
|
||||||
|
# provider that looks like a LLeMbas bug.
|
||||||
|
FORWARDED_PARAMS = frozenset(
|
||||||
|
{"temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty",
|
||||||
|
"seed", "stop"}
|
||||||
|
)
|
||||||
|
|
||||||
|
MAX_TITLE_LENGTH = 60
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_endpoint(db: DBSession, chat: Chat) -> tuple[Endpoint, str]:
|
||||||
|
"""Find the connection and model a chat should use.
|
||||||
|
|
||||||
|
Chats store the model id as text rather than a foreign key so history
|
||||||
|
survives an admin deleting a connection, which means the mapping back to a
|
||||||
|
live connection has to be resolved at send time and can legitimately fail.
|
||||||
|
"""
|
||||||
|
if not chat.model_id:
|
||||||
|
raise LLMError("This chat has no model selected.")
|
||||||
|
|
||||||
|
connection: Connection | None = None
|
||||||
|
if chat.connection_id:
|
||||||
|
connection = db.get(Connection, chat.connection_id)
|
||||||
|
|
||||||
|
if connection is None or not connection.enabled:
|
||||||
|
# The original connection is gone or disabled. Any enabled connection
|
||||||
|
# still offering this model id will do.
|
||||||
|
model = db.scalar(
|
||||||
|
select(Model)
|
||||||
|
.join(Connection)
|
||||||
|
.where(
|
||||||
|
Model.model_id == chat.model_id,
|
||||||
|
Model.enabled.is_(True),
|
||||||
|
Connection.enabled.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(Connection.position)
|
||||||
|
)
|
||||||
|
if model is None:
|
||||||
|
raise LLMError(
|
||||||
|
f"No enabled connection currently offers the model "
|
||||||
|
f"'{chat.model_id}'. Pick another model for this chat."
|
||||||
|
)
|
||||||
|
connection = model.connection
|
||||||
|
chat.connection_id = connection.id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return Endpoint.from_connection(connection), chat.model_id
|
||||||
|
|
||||||
|
|
||||||
|
def build_messages(db: DBSession, chat: Chat, *, upto: Message | None = None) -> list[dict]:
|
||||||
|
"""Assemble the message list to send upstream.
|
||||||
|
|
||||||
|
`upto` excludes the placeholder assistant row being generated into, and
|
||||||
|
everything after it.
|
||||||
|
"""
|
||||||
|
payload: list[dict[str, Any]] = []
|
||||||
|
if chat.system_prompt.strip():
|
||||||
|
payload.append({"role": ROLE_SYSTEM, "content": chat.system_prompt.strip()})
|
||||||
|
|
||||||
|
history = db.scalars(
|
||||||
|
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for message in history:
|
||||||
|
if upto is not None and message.id == upto.id:
|
||||||
|
break
|
||||||
|
# Skip turns that failed or produced nothing: sending an empty
|
||||||
|
# assistant message upsets several providers.
|
||||||
|
if message.error or not message.content.strip():
|
||||||
|
continue
|
||||||
|
payload.append({"role": message.role, "content": message.content})
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def build_request(db: DBSession, chat: Chat, *, upto: Message | None = None) -> dict[str, Any]:
|
||||||
|
params = {
|
||||||
|
key: value
|
||||||
|
for key, value in (chat.params_json or {}).items()
|
||||||
|
if key in FORWARDED_PARAMS and value not in (None, "")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"model": chat.model_id,
|
||||||
|
"messages": build_messages(db, chat, upto=upto),
|
||||||
|
**params,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_model(db: DBSession) -> tuple[str, str] | None:
|
||||||
|
"""First enabled model on the first enabled connection, or None."""
|
||||||
|
model = db.scalar(
|
||||||
|
select(Model)
|
||||||
|
.join(Connection)
|
||||||
|
.where(Model.enabled.is_(True), Connection.enabled.is_(True))
|
||||||
|
.order_by(Connection.position, Model.model_id)
|
||||||
|
)
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
return model.model_id, model.connection_id
|
||||||
|
|
||||||
|
|
||||||
|
def available_models(db: DBSession) -> list[Model]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(Model)
|
||||||
|
.join(Connection)
|
||||||
|
.where(Model.enabled.is_(True), Connection.enabled.is_(True))
|
||||||
|
.order_by(Connection.position, Model.model_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_title(text: str) -> str:
|
||||||
|
"""Derive a chat title from the opening message, without calling a model."""
|
||||||
|
cleaned = " ".join(text.split())
|
||||||
|
if not cleaned:
|
||||||
|
return "New chat"
|
||||||
|
if len(cleaned) <= MAX_TITLE_LENGTH:
|
||||||
|
return cleaned
|
||||||
|
# Prefer a word boundary, but only if it does not cut the title in half.
|
||||||
|
clipped = cleaned[:MAX_TITLE_LENGTH]
|
||||||
|
space = clipped.rfind(" ")
|
||||||
|
if space > MAX_TITLE_LENGTH * 0.6:
|
||||||
|
clipped = clipped[:space]
|
||||||
|
return clipped.rstrip(" ,.;:-") + "…"
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_title(endpoint: Endpoint, model_id: str, question: str, answer: str) -> str:
|
||||||
|
"""Ask the model for a short chat title.
|
||||||
|
|
||||||
|
Best-effort by design: any failure falls back to trimming the first
|
||||||
|
message. Naming a chat is never worth surfacing an error for.
|
||||||
|
"""
|
||||||
|
prompt = (
|
||||||
|
"Summarise this exchange as a title of at most six words. "
|
||||||
|
"Reply with the title alone: no quotes, no punctuation at the end, "
|
||||||
|
"no preamble.\n\n"
|
||||||
|
f"User: {question[:500]}\n\nAssistant: {answer[:500]}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
raw = await complete(
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
"model": model_id,
|
||||||
|
"messages": [{"role": ROLE_USER, "content": prompt}],
|
||||||
|
"max_tokens": 24,
|
||||||
|
"temperature": 0.2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except LLMError as exc:
|
||||||
|
log.debug("auto-title failed, using fallback: %s", exc)
|
||||||
|
return fallback_title(question)
|
||||||
|
|
||||||
|
title = " ".join(raw.split()).strip().strip('"“”\'')
|
||||||
|
# Small models sometimes ignore the instruction and answer the question
|
||||||
|
# instead; an over-long reply is a better signal of that than anything else.
|
||||||
|
if not title or len(title) > MAX_TITLE_LENGTH * 1.5:
|
||||||
|
return fallback_title(question)
|
||||||
|
return title[:MAX_TITLE_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
def create_message(
|
||||||
|
db: DBSession,
|
||||||
|
chat: Chat,
|
||||||
|
role: str,
|
||||||
|
content: str = "",
|
||||||
|
*,
|
||||||
|
complete_: bool = True,
|
||||||
|
model_id: str = "",
|
||||||
|
) -> Message:
|
||||||
|
message = Message(
|
||||||
|
chat_id=chat.id,
|
||||||
|
role=role,
|
||||||
|
content=content,
|
||||||
|
complete=complete_,
|
||||||
|
model_id=model_id,
|
||||||
|
)
|
||||||
|
db.add(message)
|
||||||
|
db.commit()
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def user_chats(db: DBSession, user_id: str, *, folder_id: str | None = None) -> list[Chat]:
|
||||||
|
query = select(Chat).where(Chat.user_id == user_id, Chat.archived.is_(False))
|
||||||
|
if folder_id is not None:
|
||||||
|
query = query.where(Chat.folder_id == folder_id)
|
||||||
|
return list(db.scalars(query.order_by(Chat.pinned.desc(), Chat.updated_at.desc())))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ROLE_ASSISTANT",
|
||||||
|
"ROLE_USER",
|
||||||
|
"available_models",
|
||||||
|
"build_request",
|
||||||
|
"create_message",
|
||||||
|
"default_model",
|
||||||
|
"fallback_title",
|
||||||
|
"generate_title",
|
||||||
|
"resolve_endpoint",
|
||||||
|
"user_chats",
|
||||||
|
]
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""Client for OpenAI-compatible chat endpoints.
|
||||||
|
|
||||||
|
Deliberately plain httpx rather than the official SDK. The target is not just
|
||||||
|
api.openai.com but LM Studio, vLLM, llama.cpp, Ollama's compatibility layer,
|
||||||
|
OpenRouter and anything else exposing /v1 -- and they differ in small ways. A
|
||||||
|
thin client passes request parameters through untouched and is tolerant about
|
||||||
|
what comes back, which is exactly what talking to all of them requires.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from lembas.config import settings
|
||||||
|
from lembas.db.models import Connection
|
||||||
|
from lembas.services.crypto import decrypt
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMError(Exception):
|
||||||
|
"""An upstream failure with a message fit to show a user.
|
||||||
|
|
||||||
|
Every failure path in this module raises this rather than letting an httpx
|
||||||
|
or JSON exception escape, so callers have exactly one thing to catch and
|
||||||
|
the chat UI always has something intelligible to display.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Endpoint:
|
||||||
|
"""Everything needed to call a connection, with the key already decrypted.
|
||||||
|
|
||||||
|
A frozen snapshot rather than the ORM object because streaming outlives the
|
||||||
|
request that started it, and a detached SQLAlchemy instance is a trap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
api_key: str
|
||||||
|
extra_headers: dict[str, str]
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_connection(cls, connection: Connection) -> Endpoint:
|
||||||
|
return cls(
|
||||||
|
base_url=connection.base_url.rstrip("/"),
|
||||||
|
api_key=decrypt(connection.api_key_encrypted),
|
||||||
|
extra_headers=dict(connection.extra_headers_json or {}),
|
||||||
|
name=connection.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def url(self, path: str) -> str:
|
||||||
|
# Accept both "http://host:1234" and "http://host:1234/v1" so users do
|
||||||
|
# not have to guess which form this expects.
|
||||||
|
base = self.base_url
|
||||||
|
if not base.endswith("/v1") and "/v1/" not in base:
|
||||||
|
base = f"{base}/v1"
|
||||||
|
return f"{base}/{path.lstrip('/')}"
|
||||||
|
|
||||||
|
def headers(self) -> dict[str, str]:
|
||||||
|
headers = {"Content-Type": "application/json", **self.extra_headers}
|
||||||
|
# Local endpoints frequently need no key at all; sending an empty
|
||||||
|
# bearer token makes some of them reject the request outright.
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_http_error(exc: httpx.HTTPStatusError) -> str:
|
||||||
|
"""Turn an upstream error response into something worth reading.
|
||||||
|
|
||||||
|
Providers put the useful part in wildly different places, so try the common
|
||||||
|
shapes before falling back to the raw body.
|
||||||
|
"""
|
||||||
|
status = exc.response.status_code
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
payload = exc.response.json()
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
error = payload.get("error")
|
||||||
|
if isinstance(error, dict):
|
||||||
|
detail = error.get("message", "")
|
||||||
|
elif isinstance(error, str):
|
||||||
|
detail = error
|
||||||
|
detail = detail or payload.get("message", "") or payload.get("detail", "")
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
detail = exc.response.text[:300]
|
||||||
|
|
||||||
|
friendly = {
|
||||||
|
401: "The API key was rejected.",
|
||||||
|
403: "The API key is not permitted to use this model.",
|
||||||
|
404: "The endpoint or model was not found.",
|
||||||
|
429: "Rate limited by the provider.",
|
||||||
|
}.get(status)
|
||||||
|
|
||||||
|
if friendly and detail:
|
||||||
|
return f"{friendly} {detail}"
|
||||||
|
return friendly or detail or f"The endpoint returned HTTP {status}."
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_transport_error(exc: httpx.RequestError, endpoint: Endpoint) -> LLMError:
|
||||||
|
if isinstance(exc, httpx.ConnectError):
|
||||||
|
return LLMError(
|
||||||
|
f"Could not reach {endpoint.base_url}. Is the endpoint running and "
|
||||||
|
f"the URL correct?"
|
||||||
|
)
|
||||||
|
if isinstance(exc, httpx.TimeoutException):
|
||||||
|
return LLMError(
|
||||||
|
f"{endpoint.base_url} did not respond within "
|
||||||
|
f"{settings.request_timeout:.0f}s."
|
||||||
|
)
|
||||||
|
return LLMError(f"Could not reach {endpoint.base_url}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
async def list_models(endpoint: Endpoint) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch the models a connection advertises via GET /v1/models."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(endpoint.url("models"), headers=endpoint.headers())
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise _wrap_transport_error(exc, endpoint) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise LLMError("The endpoint returned a response that was not JSON.") from exc
|
||||||
|
|
||||||
|
# The spec says {"data": [...]}, but some servers return a bare list.
|
||||||
|
entries = payload.get("data", payload) if isinstance(payload, dict) else payload
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
raise LLMError("The endpoint's model list was not in the expected format.")
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for entry in entries:
|
||||||
|
if isinstance(entry, dict) and entry.get("id"):
|
||||||
|
models.append(entry)
|
||||||
|
elif isinstance(entry, str):
|
||||||
|
models.append({"id": entry})
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_chat(
|
||||||
|
endpoint: Endpoint,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
|
"""Stream a chat completion, yielding each parsed SSE data object.
|
||||||
|
|
||||||
|
Yields the raw upstream chunks; interpreting them is the caller's job. The
|
||||||
|
terminating "[DONE]" sentinel is consumed here and not yielded.
|
||||||
|
"""
|
||||||
|
body = {**payload, "stream": True}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with (
|
||||||
|
httpx.AsyncClient(timeout=settings.request_timeout) as client,
|
||||||
|
client.stream(
|
||||||
|
"POST",
|
||||||
|
endpoint.url("chat/completions"),
|
||||||
|
headers=endpoint.headers(),
|
||||||
|
json=body,
|
||||||
|
) as response,
|
||||||
|
):
|
||||||
|
if response.status_code >= 400:
|
||||||
|
# The body has not been read yet on a streaming response, and
|
||||||
|
# the error detail is in it.
|
||||||
|
await response.aread()
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith(":"):
|
||||||
|
continue # keep-alive comment
|
||||||
|
if not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
data = line[5:].strip()
|
||||||
|
if data == "[DONE]":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
yield json.loads(data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# A malformed frame is not worth killing a reply over.
|
||||||
|
log.warning("skipping unparseable SSE frame: %.120s", data)
|
||||||
|
continue
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise _wrap_transport_error(exc, endpoint) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def complete(endpoint: Endpoint, payload: dict[str, Any]) -> str:
|
||||||
|
"""Non-streaming completion. Used for short internal calls like auto-titling."""
|
||||||
|
body = {**payload, "stream": False}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
endpoint.url("chat/completions"), headers=endpoint.headers(), json=body
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise _wrap_transport_error(exc, endpoint) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise LLMError("The endpoint returned a response that was not JSON.") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
return data["choices"][0]["message"]["content"] or ""
|
||||||
|
except (KeyError, IndexError, TypeError) as exc:
|
||||||
|
raise LLMError("The endpoint returned no completion.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def delta_text(chunk: dict[str, Any]) -> str:
|
||||||
|
"""Pull the text out of one streamed chunk, tolerating provider variation."""
|
||||||
|
try:
|
||||||
|
choices = chunk.get("choices") or []
|
||||||
|
if not choices:
|
||||||
|
return ""
|
||||||
|
delta = choices[0].get("delta") or {}
|
||||||
|
content = delta.get("content")
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
# Some providers send content as a list of typed parts even in deltas.
|
||||||
|
if isinstance(content, list):
|
||||||
|
return "".join(
|
||||||
|
part.get("text", "")
|
||||||
|
for part in content
|
||||||
|
if isinstance(part, dict) and part.get("type") == "text"
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return ""
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Render assistant messages from Markdown to sanitised HTML.
|
||||||
|
|
||||||
|
Rendering happens on the server, in Python, so there is no JavaScript Markdown
|
||||||
|
library to vendor and the streamed and final views cannot disagree about how
|
||||||
|
something should look.
|
||||||
|
|
||||||
|
The output is sanitised with nh3 (Rust ammonia). Model output is untrusted
|
||||||
|
input: it routinely contains HTML, and a model can be talked into emitting a
|
||||||
|
script tag, so this is a real boundary and not a formality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import html
|
||||||
|
|
||||||
|
import nh3
|
||||||
|
from markdown_it import MarkdownIt
|
||||||
|
from pygments import highlight
|
||||||
|
from pygments.formatters import HtmlFormatter
|
||||||
|
from pygments.lexers import get_lexer_by_name, guess_lexer
|
||||||
|
from pygments.util import ClassNotFound
|
||||||
|
|
||||||
|
# Class-based highlighting; the colours come from theme tokens in chat.css, so
|
||||||
|
# code blocks follow the active theme instead of carrying their own palette.
|
||||||
|
_FORMATTER = HtmlFormatter(nowrap=True, classprefix="pg-")
|
||||||
|
|
||||||
|
ALLOWED_TAGS = {
|
||||||
|
"p", "br", "hr", "div", "span",
|
||||||
|
"strong", "em", "del", "sub", "sup", "mark",
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"ul", "ol", "li",
|
||||||
|
"blockquote", "pre", "code",
|
||||||
|
"table", "thead", "tbody", "tr", "th", "td",
|
||||||
|
"a", "img",
|
||||||
|
}
|
||||||
|
|
||||||
|
ALLOWED_ATTRIBUTES = {
|
||||||
|
# "rel" is intentionally absent: nh3 rejects it here when link_rel is set,
|
||||||
|
# because link_rel below is what writes it.
|
||||||
|
"a": {"href", "title", "target"},
|
||||||
|
"img": {"src", "alt", "title"},
|
||||||
|
"code": {"class"},
|
||||||
|
"pre": {"class"},
|
||||||
|
"span": {"class"},
|
||||||
|
"div": {"class"},
|
||||||
|
"td": {"align"},
|
||||||
|
"th": {"align"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# javascript: and data: URLs are the obvious injection route through a link.
|
||||||
|
ALLOWED_URL_SCHEMES = {"http", "https", "mailto"}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_fence(tokens, idx, _options, _env) -> str:
|
||||||
|
"""Render a fenced code block.
|
||||||
|
|
||||||
|
This replaces the renderer's `fence` rule outright rather than using
|
||||||
|
markdown-it's `highlight` option, because that option re-wraps whatever it
|
||||||
|
is given in <pre><code> unless the string already starts with "<pre" --
|
||||||
|
which would nest a second <pre> inside the wrapper this returns.
|
||||||
|
"""
|
||||||
|
token = tokens[idx]
|
||||||
|
code = token.content
|
||||||
|
language = (token.info or "").strip().split()[0] if token.info else ""
|
||||||
|
|
||||||
|
lexer = None
|
||||||
|
if language:
|
||||||
|
try:
|
||||||
|
lexer = get_lexer_by_name(language, stripall=False)
|
||||||
|
except (ClassNotFound, ValueError):
|
||||||
|
lexer = None
|
||||||
|
elif code.strip():
|
||||||
|
# Guessing is only worth it for a decent sample; on two lines of text
|
||||||
|
# Pygments guesses confidently and wrongly.
|
||||||
|
try:
|
||||||
|
lexer = guess_lexer(code) if len(code) > 80 else None
|
||||||
|
except (ClassNotFound, ValueError):
|
||||||
|
lexer = None
|
||||||
|
|
||||||
|
if lexer is None:
|
||||||
|
body = nh3.clean_text(code)
|
||||||
|
label = language
|
||||||
|
else:
|
||||||
|
body = highlight(code, lexer, _FORMATTER)
|
||||||
|
label = language or (lexer.aliases[0] if lexer.aliases else "")
|
||||||
|
|
||||||
|
label_html = (
|
||||||
|
f'<div class="code-block__label">{nh3.clean_text(label)}</div>' if label else ""
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f'<div class="code-block">{label_html}'
|
||||||
|
f'<pre class="code-block__pre"><code>{body}</code></pre></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=1)
|
||||||
|
def _parser() -> MarkdownIt:
|
||||||
|
md = MarkdownIt("commonmark", {"linkify": True, "typographer": False})
|
||||||
|
md.enable(["table", "strikethrough", "linkify"])
|
||||||
|
md.renderer.rules["fence"] = _render_fence
|
||||||
|
return md
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown(text: str) -> str:
|
||||||
|
"""Markdown to safe HTML, ready to drop into a message bubble."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
html = _parser().render(text)
|
||||||
|
return nh3.clean(
|
||||||
|
html,
|
||||||
|
tags=ALLOWED_TAGS,
|
||||||
|
attributes=ALLOWED_ATTRIBUTES,
|
||||||
|
url_schemes=ALLOWED_URL_SCHEMES,
|
||||||
|
# Anything opened from a model's output is untrusted; noopener stops it
|
||||||
|
# reaching back through window.opener.
|
||||||
|
link_rel="nofollow noopener noreferrer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def escape_text(text: str) -> str:
|
||||||
|
"""Escape a plain-text run for insertion as HTML element content.
|
||||||
|
|
||||||
|
Used for user messages and for partial assistant text mid-stream, where the
|
||||||
|
content is not yet complete enough to parse as Markdown.
|
||||||
|
|
||||||
|
html.escape rather than nh3.clean_text: escaping the three structural
|
||||||
|
characters is all that is needed for a text node, and it escapes character
|
||||||
|
by character, so escaping a stream chunk-by-chunk gives the same result as
|
||||||
|
escaping the whole string at once. nh3.clean_text also escapes spaces and
|
||||||
|
slashes, which triples the size of a streamed token for no benefit.
|
||||||
|
"""
|
||||||
|
return html.escape(text, quote=False)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Server-sent event framing.
|
||||||
|
|
||||||
|
Small, but worth isolating: getting the wire format subtly wrong is the usual
|
||||||
|
cause of a stream that "works" until a model emits a newline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Every 15s of silence, so proxies that kill idle connections (nginx defaults
|
||||||
|
# to 60s) do not drop a stream while a model is still thinking.
|
||||||
|
KEEPALIVE = ": keepalive\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def event(name: str, data: str) -> str:
|
||||||
|
"""Frame one SSE event.
|
||||||
|
|
||||||
|
A payload containing newlines must be split across several `data:` lines;
|
||||||
|
the browser rejoins them with "\\n". Sending a raw newline inside a single
|
||||||
|
data line silently truncates the event, which is exactly what happens the
|
||||||
|
first time a model emits a code block.
|
||||||
|
"""
|
||||||
|
lines = data.split("\n")
|
||||||
|
body = "".join(f"data: {line}\n" for line in lines)
|
||||||
|
return f"event: {name}\n{body}\n"
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/* Administration screens. */
|
||||||
|
|
||||||
|
.admin-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-strong) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page {
|
||||||
|
max-width: 46rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--sp-6) var(--sp-5) var(--sp-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-lede {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
margin-bottom: var(--sp-6);
|
||||||
|
max-width: 42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin: var(--sp-8) 0 var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--sp-5);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
.card__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid { display: block; }
|
||||||
|
.form-grid .field:last-of-type { margin-bottom: 0; }
|
||||||
|
.field--actions { margin-top: var(--sp-5); margin-bottom: 0; }
|
||||||
|
|
||||||
|
.connection__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.connection__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin-top: var(--sp-5);
|
||||||
|
padding-top: var(--sp-4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reachable status at a glance: green working, red errored, grey disabled. */
|
||||||
|
.status-dot {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
flex: none;
|
||||||
|
background: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.status-dot.is-ok { background: var(--success); }
|
||||||
|
.status-dot.is-bad { background: var(--danger); }
|
||||||
|
.status-dot.is-off { background: var(--ink-faint); }
|
||||||
|
|
||||||
|
.model-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.model-list__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-2) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.model-list__item:last-child { border-bottom: 0; }
|
||||||
|
.model-list__id {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.is-disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.nav-item.is-disabled:hover { background: none; color: var(--ink-muted); }
|
||||||
|
|
||||||
|
.field__hint code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.92em;
|
||||||
|
padding: 0.05em 0.3em;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--code-bg);
|
||||||
|
border: 1px solid var(--code-border);
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
/*
|
||||||
|
Application styles.
|
||||||
|
|
||||||
|
Rules here resolve colour, spacing and radius through the variables in
|
||||||
|
tokens.css and never hard-code a value. Layout is flexbox and grid only --
|
||||||
|
no framework, no preprocessor, no build step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* --- Reset ---------------------------------------------------------------- */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p { margin: 0 0 var(--sp-4); }
|
||||||
|
p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
a:hover { color: var(--accent-hover); }
|
||||||
|
|
||||||
|
button, input, textarea, select {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A single, consistent focus ring. Never remove it without a replacement. */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
:focus:not(:focus-visible) { outline: none; }
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px; height: 1px;
|
||||||
|
padding: 0; margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Icons ---------------------------------------------------------------- */
|
||||||
|
.icon {
|
||||||
|
width: 1.25em;
|
||||||
|
height: 1.25em;
|
||||||
|
flex: none;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.icon--sm { width: 1em; height: 1em; }
|
||||||
|
.icon--lg { width: 1.5em; height: 1.5em; }
|
||||||
|
/* The leaf is a filled silhouette, not a stroked pictogram. */
|
||||||
|
.icon--leaf { fill: currentColor; stroke: none; }
|
||||||
|
|
||||||
|
/* --- Buttons -------------------------------------------------------------- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
.btn--primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
border-color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger { color: var(--danger); border-color: var(--border); }
|
||||||
|
.btn--danger:hover:not(:disabled) {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost { background: transparent; border-color: transparent; }
|
||||||
|
.btn--ghost:hover:not(:disabled) { background: var(--surface-hover); border-color: transparent; }
|
||||||
|
|
||||||
|
.btn--icon {
|
||||||
|
padding: 0.4rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.btn--icon:hover:not(:disabled) { background: var(--surface-hover); color: var(--ink); }
|
||||||
|
|
||||||
|
.btn--block { width: 100%; }
|
||||||
|
.btn--sm { padding: 0.3rem 0.6rem; font-size: var(--text-xs); }
|
||||||
|
|
||||||
|
/* --- Forms ---------------------------------------------------------------- */
|
||||||
|
.field { margin-bottom: var(--sp-4); }
|
||||||
|
.field__label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--sp-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.field__hint {
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.textarea,
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
.input:focus,
|
||||||
|
.textarea:focus,
|
||||||
|
.select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
.input::placeholder, .textarea::placeholder { color: var(--ink-faint); }
|
||||||
|
.textarea { resize: vertical; min-height: 5rem; line-height: var(--leading-normal); }
|
||||||
|
.input--mono { font-family: var(--font-mono); font-size: var(--text-sm); }
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.checkbox input { accent-color: var(--accent); width: 1rem; height: 1rem; }
|
||||||
|
|
||||||
|
/* --- Alerts --------------------------------------------------------------- */
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left-width: 3px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.alert--error { border-left-color: var(--danger); background: var(--danger-soft); color: var(--ink); }
|
||||||
|
.alert--success { border-left-color: var(--success); background: var(--success-soft); }
|
||||||
|
.alert--warning { border-left-color: var(--warning); background: var(--warning-soft); }
|
||||||
|
.alert__icon { color: var(--danger); flex: none; margin-top: 0.15rem; }
|
||||||
|
|
||||||
|
/* --- Badges --------------------------------------------------------------- */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--surface-active);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.badge--gold { background: var(--gold-soft); color: var(--gold); }
|
||||||
|
.badge--success { background: var(--success-soft); color: var(--success); }
|
||||||
|
.badge--danger { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
|
||||||
|
/* --- Application shell ---------------------------------------------------- */
|
||||||
|
.shell {
|
||||||
|
display: flex;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
transition: margin-left var(--transition);
|
||||||
|
}
|
||||||
|
.sidebar[hidden] { display: none; }
|
||||||
|
|
||||||
|
.sidebar__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
height: var(--header-height);
|
||||||
|
padding: 0 var(--sp-3);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.sidebar__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.sidebar__brand:hover { color: var(--ink); }
|
||||||
|
.sidebar__brand .brand-mark { width: 1.6rem; height: 1.6rem; flex: none; }
|
||||||
|
.sidebar__brand .brand-llm { color: var(--gold); }
|
||||||
|
|
||||||
|
.sidebar__actions { padding: 0 var(--sp-3) var(--sp-3); display: flex; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.sidebar__scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 var(--sp-2) var(--sp-3);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-strong) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__footer {
|
||||||
|
flex: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
height: var(--header-height);
|
||||||
|
flex: none;
|
||||||
|
padding: 0 var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.topbar__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.topbar__spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* --- Sidebar navigation --------------------------------------------------- */
|
||||||
|
.nav-group { margin-bottom: var(--sp-4); }
|
||||||
|
.nav-group__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--sp-2) var(--sp-2) var(--sp-1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: 0.4rem var(--sp-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.nav-item:hover { background: var(--surface-hover); color: var(--ink); }
|
||||||
|
.nav-item.is-active { background: var(--surface-active); color: var(--ink); font-weight: 500; }
|
||||||
|
.nav-item__label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* Row actions stay hidden until the row is hovered or focused within, so the
|
||||||
|
list reads calmly, but they remain keyboard reachable. */
|
||||||
|
.nav-item__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.1rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
.nav-item:hover .nav-item__actions,
|
||||||
|
.nav-item:focus-within .nav-item__actions { opacity: 1; }
|
||||||
|
|
||||||
|
.nav-empty {
|
||||||
|
padding: var(--sp-3) var(--sp-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Auth screens --------------------------------------------------------- */
|
||||||
|
.auth {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--sp-6);
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 60% 50% at 50% 0%, var(--gold-soft), transparent 70%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
.auth__card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 25rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--sp-8);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.auth__brand { display: grid; place-items: center; gap: var(--sp-3); margin-bottom: var(--sp-6); }
|
||||||
|
.auth__brand .brand-mark { width: 3.5rem; height: 3.5rem; }
|
||||||
|
.auth__title {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.auth__subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
}
|
||||||
|
.auth__footer {
|
||||||
|
margin-top: var(--sp-5);
|
||||||
|
padding-top: var(--sp-4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Empty states --------------------------------------------------------- */
|
||||||
|
.empty {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-10);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty__mark { width: 4.5rem; height: 4.5rem; opacity: 0.85; }
|
||||||
|
.empty__title { font-size: var(--text-xl); font-family: var(--font-display); }
|
||||||
|
.empty__text {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
max-width: 32rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Utilities ------------------------------------------------------------ */
|
||||||
|
.stack > * + * { margin-top: var(--sp-4); }
|
||||||
|
.row { display: flex; align-items: center; gap: var(--sp-3); }
|
||||||
|
.row--between { justify-content: space-between; }
|
||||||
|
.muted { color: var(--ink-muted); }
|
||||||
|
.faint { color: var(--ink-faint); }
|
||||||
|
.text-sm { font-size: var(--text-sm); }
|
||||||
|
.text-xs { font-size: var(--text-xs); }
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* --- Small screens -------------------------------------------------------- */
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
z-index: 40;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.sidebar[data-collapsed="true"] { display: none; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
/*
|
||||||
|
Chat thread, composer, message bodies and code blocks.
|
||||||
|
|
||||||
|
Loaded only on chat pages. Like app.css, every value resolves through
|
||||||
|
tokens.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* --- Thread --------------------------------------------------------------- */
|
||||||
|
.thread-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-strong) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread {
|
||||||
|
max-width: var(--thread-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--sp-6) var(--sp-5) var(--sp-8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread__intro {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--sp-12) 0 var(--sp-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Messages ------------------------------------------------------------- */
|
||||||
|
.msg {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2rem 1fr;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg__gutter {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.msg__mark { width: 2rem; height: 2rem; }
|
||||||
|
.msg__initial {
|
||||||
|
width: 2rem; height: 2rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--surface-active);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg__main { min-width: 0; }
|
||||||
|
|
||||||
|
.msg__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-bottom: var(--sp-1);
|
||||||
|
}
|
||||||
|
.msg__author {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.msg__model {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg__body {
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
/* User turns and mid-stream assistant text are plain text, so newlines and
|
||||||
|
runs of spaces have to survive. */
|
||||||
|
.msg__body--plain,
|
||||||
|
.msg__body--streaming { white-space: pre-wrap; }
|
||||||
|
|
||||||
|
.msg--user .msg__body--plain {
|
||||||
|
background: var(--bubble-user);
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg__error { margin: var(--sp-2) 0; align-items: flex-start; }
|
||||||
|
|
||||||
|
/* --- Streaming indicator -------------------------------------------------- */
|
||||||
|
/* Shown until the first token arrives, then hidden by the sibling selector
|
||||||
|
below -- no JavaScript involved in either direction. */
|
||||||
|
.msg__waiting { padding: var(--sp-2) 0; }
|
||||||
|
.msg__body--streaming:not(:empty) + .msg__waiting { display: none; }
|
||||||
|
|
||||||
|
.dots { display: inline-flex; gap: 0.25rem; align-items: center; }
|
||||||
|
.dots i {
|
||||||
|
width: 0.4rem;
|
||||||
|
height: 0.4rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--ink-faint);
|
||||||
|
animation: dot-pulse 1.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.dots i:nth-child(2) { animation-delay: 0.18s; }
|
||||||
|
.dots i:nth-child(3) { animation-delay: 0.36s; }
|
||||||
|
|
||||||
|
@keyframes dot-pulse {
|
||||||
|
0%, 60%, 100% { opacity: 0.28; transform: translateY(0); }
|
||||||
|
30% { opacity: 1; transform: translateY(-2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A caret trailing the text while it streams. */
|
||||||
|
.msg__body--streaming::after {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.45rem;
|
||||||
|
height: 1.05em;
|
||||||
|
margin-left: 1px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
background: var(--gold);
|
||||||
|
opacity: 0.75;
|
||||||
|
animation: caret 1.05s steps(1) infinite;
|
||||||
|
}
|
||||||
|
@keyframes caret { 0%, 49% { opacity: 0.75; } 50%, 100% { opacity: 0; } }
|
||||||
|
|
||||||
|
/* --- Message actions ------------------------------------------------------ */
|
||||||
|
.msg__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
.msg:hover .msg__actions,
|
||||||
|
.msg:focus-within .msg__actions { opacity: 1; }
|
||||||
|
.msg__actions .is-copied { color: var(--success); }
|
||||||
|
|
||||||
|
/* --- Rendered Markdown ---------------------------------------------------- */
|
||||||
|
.msg__body > :first-child { margin-top: 0; }
|
||||||
|
.msg__body > :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.msg__body h1, .msg__body h2, .msg__body h3,
|
||||||
|
.msg__body h4, .msg__body h5, .msg__body h6 {
|
||||||
|
margin: var(--sp-5) 0 var(--sp-2);
|
||||||
|
}
|
||||||
|
.msg__body h1 { font-size: var(--text-xl); }
|
||||||
|
.msg__body h2 { font-size: var(--text-lg); }
|
||||||
|
.msg__body h3 { font-size: var(--text-md); }
|
||||||
|
|
||||||
|
.msg__body ul, .msg__body ol { margin: 0 0 var(--sp-4); padding-left: var(--sp-6); }
|
||||||
|
.msg__body li { margin-bottom: var(--sp-1); }
|
||||||
|
|
||||||
|
.msg__body blockquote {
|
||||||
|
margin: 0 0 var(--sp-4);
|
||||||
|
padding: var(--sp-1) var(--sp-4);
|
||||||
|
border-left: 3px solid var(--border-strong);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg__body hr { border: 0; border-top: 1px solid var(--border); margin: var(--sp-5) 0; }
|
||||||
|
|
||||||
|
.msg__body :not(pre) > code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.875em;
|
||||||
|
padding: 0.13em 0.36em;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--code-bg);
|
||||||
|
border: 1px solid var(--code-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg__body table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0 0 var(--sp-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.msg__body th, .msg__body td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.msg__body th { background: var(--surface); font-weight: 600; }
|
||||||
|
|
||||||
|
.msg__body img { max-width: 100%; height: auto; border-radius: var(--radius); }
|
||||||
|
|
||||||
|
/* --- Code blocks ---------------------------------------------------------- */
|
||||||
|
.code-block {
|
||||||
|
margin: 0 0 var(--sp-4);
|
||||||
|
border: 1px solid var(--code-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--code-bg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.code-block__label {
|
||||||
|
padding: var(--sp-1) var(--sp-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
border-bottom: 1px solid var(--code-border);
|
||||||
|
background: color-mix(in srgb, var(--code-bg) 60%, var(--surface));
|
||||||
|
}
|
||||||
|
.code-block__pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.code-block__pre code { font-family: inherit; background: none; border: 0; padding: 0; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
Pygments token colours, mapped onto theme tokens rather than a fixed scheme,
|
||||||
|
so code follows the active theme. classprefix "pg-" is set in markdown.py.
|
||||||
|
*/
|
||||||
|
.pg-c, .pg-c1, .pg-cm, .pg-cs, .pg-cp { color: var(--ink-faint); font-style: italic; }
|
||||||
|
.pg-k, .pg-kn, .pg-kd, .pg-kc, .pg-kr, .pg-kt { color: var(--gold); }
|
||||||
|
.pg-s, .pg-s1, .pg-s2, .pg-sb, .pg-sd, .pg-se, .pg-sh, .pg-si, .pg-sx { color: var(--success); }
|
||||||
|
.pg-m, .pg-mi, .pg-mf, .pg-mh, .pg-mo { color: var(--danger); }
|
||||||
|
.pg-nf, .pg-nd { color: var(--accent); }
|
||||||
|
.pg-nc, .pg-nn { color: var(--accent-hover); font-weight: 600; }
|
||||||
|
.pg-nb, .pg-bp { color: var(--accent); }
|
||||||
|
.pg-nv, .pg-vi, .pg-vg, .pg-vc { color: var(--ink); }
|
||||||
|
.pg-o, .pg-ow, .pg-p { color: var(--ink-muted); }
|
||||||
|
.pg-err { color: var(--danger); }
|
||||||
|
.pg-gd { color: var(--danger); }
|
||||||
|
.pg-gi { color: var(--success); }
|
||||||
|
|
||||||
|
/* --- Composer ------------------------------------------------------------- */
|
||||||
|
.composer {
|
||||||
|
flex: none;
|
||||||
|
padding: var(--sp-3) var(--sp-5) var(--sp-4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.composer__form {
|
||||||
|
max-width: var(--thread-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: var(--sp-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--surface);
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
.composer__form:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
.composer__input {
|
||||||
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
resize: none;
|
||||||
|
padding: var(--sp-2);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
max-height: 20rem;
|
||||||
|
}
|
||||||
|
.composer__input:focus { outline: none; }
|
||||||
|
.composer__send { border-radius: var(--radius-full); padding: 0.55rem 0.7rem; }
|
||||||
|
.composer__hint {
|
||||||
|
max-width: var(--thread-max-width);
|
||||||
|
margin: var(--sp-2) auto 0;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select--compact {
|
||||||
|
width: auto;
|
||||||
|
max-width: 16rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Folders -------------------------------------------------------------- */
|
||||||
|
.folder__row { padding-right: var(--sp-1); }
|
||||||
|
.folder__toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.folder__chevron {
|
||||||
|
display: inline-flex;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
.folder__chevron.is-open { transform: rotate(90deg); }
|
||||||
|
.folder__contents { padding-left: var(--sp-4); }
|
||||||
|
|
||||||
|
.nav-item__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Theme toggle --------------------------------------------------------- */
|
||||||
|
/* Only the icon for the theme you would switch TO is shown. */
|
||||||
|
:root[data-theme="moria"] .theme-icon--dark { display: none; }
|
||||||
|
:root[data-theme="shire"] .theme-icon--light { display: none; }
|
||||||
|
|
||||||
|
/* Alpine sets x-cloak until it has initialised; without this, collapsed
|
||||||
|
folders flash open on every page load. */
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/*
|
||||||
|
Design tokens.
|
||||||
|
|
||||||
|
Every colour, space and radius in the application resolves through a variable
|
||||||
|
declared here. Component CSS must never hard-code a hex value -- that is what
|
||||||
|
makes adding a theme a matter of writing one new block rather than auditing
|
||||||
|
every stylesheet.
|
||||||
|
|
||||||
|
Themes are selected with data-theme on <html>. `moria` is the default and is
|
||||||
|
declared on :root so the page is styled even before the theme script runs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* --- Type ------------------------------------------------------------- */
|
||||||
|
--font-display: "Iowan Old Style", "Palatino Linotype", Palatino, Palladio,
|
||||||
|
"URW Palladio L", "Book Antiqua", Baskerville, Georgia, serif;
|
||||||
|
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
|
Arial, sans-serif;
|
||||||
|
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code",
|
||||||
|
"Cascadia Code", Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
--text-xs: 0.75rem;
|
||||||
|
--text-sm: 0.8125rem;
|
||||||
|
--text-base: 0.9375rem;
|
||||||
|
--text-md: 1rem;
|
||||||
|
--text-lg: 1.125rem;
|
||||||
|
--text-xl: 1.375rem;
|
||||||
|
--text-2xl: 1.75rem;
|
||||||
|
--text-3xl: 2.25rem;
|
||||||
|
|
||||||
|
--leading-tight: 1.25;
|
||||||
|
--leading-normal: 1.6;
|
||||||
|
--leading-relaxed: 1.75;
|
||||||
|
|
||||||
|
/* --- Space (4px scale) ------------------------------------------------ */
|
||||||
|
--sp-1: 0.25rem;
|
||||||
|
--sp-2: 0.5rem;
|
||||||
|
--sp-3: 0.75rem;
|
||||||
|
--sp-4: 1rem;
|
||||||
|
--sp-5: 1.25rem;
|
||||||
|
--sp-6: 1.5rem;
|
||||||
|
--sp-8: 2rem;
|
||||||
|
--sp-10: 2.5rem;
|
||||||
|
--sp-12: 3rem;
|
||||||
|
--sp-16: 4rem;
|
||||||
|
|
||||||
|
/* --- Radius & shadow -------------------------------------------------- */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 18px;
|
||||||
|
--radius-full: 999px;
|
||||||
|
|
||||||
|
/* --- Layout ----------------------------------------------------------- */
|
||||||
|
--sidebar-width: 17rem;
|
||||||
|
--thread-max-width: 48rem;
|
||||||
|
--header-height: 3.25rem;
|
||||||
|
|
||||||
|
--transition-fast: 120ms ease;
|
||||||
|
--transition: 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
MORIA (default, dark)
|
||||||
|
|
||||||
|
Deep stone and lamplight: the halls under the mountain. Surfaces are cool and
|
||||||
|
near-neutral so the gold and mithril accents carry all the colour.
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
:root,
|
||||||
|
:root[data-theme="moria"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--bg: #101317;
|
||||||
|
--bg-sunken: #0B0E11;
|
||||||
|
--surface: #171B21;
|
||||||
|
--surface-raised: #1E242B;
|
||||||
|
--surface-hover: #232A32;
|
||||||
|
--surface-active: #2A323B;
|
||||||
|
|
||||||
|
--border: #2A313A;
|
||||||
|
--border-strong: #3A434E;
|
||||||
|
|
||||||
|
--ink: #E4E8EC;
|
||||||
|
--ink-muted: #A2ADB8;
|
||||||
|
--ink-faint: #6E7883;
|
||||||
|
--ink-inverse: #0B0E11;
|
||||||
|
|
||||||
|
/* Mithril: the cool primary, used for focus and interactive accents. */
|
||||||
|
--accent: #8FB3CC;
|
||||||
|
--accent-hover: #A9C6DA;
|
||||||
|
--accent-ink: #0B0E11;
|
||||||
|
--accent-soft: rgba(143, 179, 204, 0.14);
|
||||||
|
|
||||||
|
/* Rune gold: the warm accent. Brand colour, and the assistant's mark. */
|
||||||
|
--gold: #E0B252;
|
||||||
|
--gold-hover: #EDC46F;
|
||||||
|
--gold-soft: rgba(224, 178, 82, 0.13);
|
||||||
|
|
||||||
|
/* Ember: destructive actions and errors. */
|
||||||
|
--danger: #E2795A;
|
||||||
|
--danger-hover: #EC8E72;
|
||||||
|
--danger-soft: rgba(226, 121, 90, 0.14);
|
||||||
|
|
||||||
|
--success: #7FB77E;
|
||||||
|
--success-soft: rgba(127, 183, 126, 0.14);
|
||||||
|
--warning: #DFAE58;
|
||||||
|
--warning-soft: rgba(223, 174, 88, 0.14);
|
||||||
|
|
||||||
|
--bubble-user: #232B34;
|
||||||
|
--bubble-assistant: transparent;
|
||||||
|
--code-bg: #0C0F13;
|
||||||
|
--code-border: #262D36;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||||
|
--shadow-lg: 0 12px 34px rgba(0, 0, 0, 0.55);
|
||||||
|
|
||||||
|
--scrim: rgba(6, 8, 10, 0.66);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
SHIRE (light)
|
||||||
|
|
||||||
|
Parchment, ink and moss: warm, low-contrast, easy to read for a long time.
|
||||||
|
Backgrounds are deliberately off-white -- pure #FFF next to the gold accent
|
||||||
|
reads as clinical rather than as paper.
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
:root[data-theme="shire"] {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--bg: #F6F1E4;
|
||||||
|
--bg-sunken: #EDE6D4;
|
||||||
|
--surface: #FDFBF5;
|
||||||
|
--surface-raised: #FFFFFF;
|
||||||
|
--surface-hover: #F1EADA;
|
||||||
|
--surface-active: #E7DEC9;
|
||||||
|
|
||||||
|
--border: #DED3BB;
|
||||||
|
--border-strong: #C6B896;
|
||||||
|
|
||||||
|
--ink: #2C2419;
|
||||||
|
--ink-muted: #6A5C48;
|
||||||
|
--ink-faint: #94856D;
|
||||||
|
--ink-inverse: #FDFBF5;
|
||||||
|
|
||||||
|
/* Hobbit-door blue-green: the cool primary. */
|
||||||
|
--accent: #3E6B7A;
|
||||||
|
--accent-hover: #325867;
|
||||||
|
--accent-ink: #FDFBF5;
|
||||||
|
--accent-soft: rgba(62, 107, 122, 0.12);
|
||||||
|
|
||||||
|
--gold: #A8801A;
|
||||||
|
--gold-hover: #8E6B12;
|
||||||
|
--gold-soft: rgba(168, 128, 26, 0.13);
|
||||||
|
|
||||||
|
--danger: #A6432B;
|
||||||
|
--danger-hover: #8C3722;
|
||||||
|
--danger-soft: rgba(166, 67, 43, 0.11);
|
||||||
|
|
||||||
|
--success: #4F7A3F;
|
||||||
|
--success-soft: rgba(79, 122, 63, 0.12);
|
||||||
|
--warning: #98701A;
|
||||||
|
--warning-soft: rgba(152, 112, 26, 0.13);
|
||||||
|
|
||||||
|
--bubble-user: #EDE4CF;
|
||||||
|
--bubble-assistant: transparent;
|
||||||
|
--code-bg: #F2EBD9;
|
||||||
|
--code-border: #DED3BB;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(72, 58, 34, 0.09);
|
||||||
|
--shadow: 0 4px 14px rgba(72, 58, 34, 0.11);
|
||||||
|
--shadow-lg: 0 12px 34px rgba(72, 58, 34, 0.16);
|
||||||
|
|
||||||
|
--scrim: rgba(44, 36, 25, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect a stated preference for reduced motion everywhere, at once. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 420"
|
||||||
|
width="1280" height="420" role="img"
|
||||||
|
aria-label="LLeMbas - Waybread for the long road of thought">
|
||||||
|
<title>LLeMbas</title>
|
||||||
|
<desc>Waybread for the long road of thought. 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="#080B0F"/>
|
||||||
|
<stop offset="0.62" stop-color="#101822"/>
|
||||||
|
<stop offset="1" stop-color="#1A2530"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="b-glow" cx="0.5" cy="0.54" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#C9A227" stop-opacity="0.22"/>
|
||||||
|
<stop offset="1" stop-color="#C9A227" 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>
|
||||||
|
|
||||||
|
<linearGradient id="b-wafer" x1="0" y1="0" x2="0.3" y2="1">
|
||||||
|
<stop offset="0" stop-color="#EACB74"/>
|
||||||
|
<stop offset="0.5" stop-color="#C9A227"/>
|
||||||
|
<stop offset="1" stop-color="#916F13"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="b-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||||
|
<stop offset="0" stop-color="#93A5B6"/>
|
||||||
|
<stop offset="0.4" stop-color="#F1F6FA"/>
|
||||||
|
<stop offset="1" stop-color="#B8C7D5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="b-clip">
|
||||||
|
<rect x="6" y="6" width="52" height="52" rx="13"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="1280" height="420" fill="url(#b-sky)"/>
|
||||||
|
<g fill="#FFFFFF">
|
||||||
|
<circle cx="579.0" cy="167.9" r="1.80" opacity="0.49"/>
|
||||||
|
<circle cx="650.0" cy="176.2" r="0.84" opacity="0.52"/>
|
||||||
|
<circle cx="806.2" cy="237.9" r="0.72" opacity="0.38"/>
|
||||||
|
<circle cx="116.1" cy="242.9" r="1.50" opacity="0.21"/>
|
||||||
|
<circle cx="1257.2" cy="289.4" r="1.45" opacity="0.59"/>
|
||||||
|
<circle cx="201.6" cy="4.5" r="1.29" opacity="0.22"/>
|
||||||
|
<circle cx="243.5" cy="72.6" r="0.64" opacity="0.49"/>
|
||||||
|
<circle cx="563.9" cy="252.7" r="1.27" opacity="0.61"/>
|
||||||
|
<circle cx="639.7" cy="198.7" r="1.19" opacity="0.37"/>
|
||||||
|
<circle cx="1277.0" cy="298.7" r="1.69" opacity="0.65"/>
|
||||||
|
<circle cx="403.6" cy="68.9" r="0.98" opacity="0.23"/>
|
||||||
|
<circle cx="980.8" cy="120.1" r="1.70" opacity="0.44"/>
|
||||||
|
<circle cx="1226.3" cy="254.2" r="0.60" opacity="0.32"/>
|
||||||
|
<circle cx="1165.1" cy="141.0" r="1.87" opacity="0.45"/>
|
||||||
|
<circle cx="93.5" cy="188.8" r="1.61" opacity="0.36"/>
|
||||||
|
<circle cx="111.5" cy="99.8" r="1.85" opacity="0.69"/>
|
||||||
|
<circle cx="151.0" cy="73.9" r="0.73" opacity="0.22"/>
|
||||||
|
<circle cx="1020.2" cy="53.3" r="1.33" opacity="0.48"/>
|
||||||
|
<circle cx="244.1" cy="219.6" r="0.77" opacity="0.61"/>
|
||||||
|
<circle cx="149.1" cy="126.2" r="0.88" opacity="0.36"/>
|
||||||
|
<circle cx="1242.8" cy="241.0" r="1.00" opacity="0.77"/>
|
||||||
|
<circle cx="269.7" cy="118.3" r="1.71" opacity="0.61"/>
|
||||||
|
<circle cx="128.4" cy="296.8" r="0.88" opacity="0.35"/>
|
||||||
|
<circle cx="989.0" cy="98.7" r="0.99" opacity="0.23"/>
|
||||||
|
<circle cx="115.3" cy="174.8" r="0.92" opacity="0.58"/>
|
||||||
|
<circle cx="475.8" cy="136.0" r="1.85" opacity="0.50"/>
|
||||||
|
<circle cx="735.5" cy="260.0" r="0.84" opacity="0.28"/>
|
||||||
|
<circle cx="1162.8" cy="245.3" r="0.92" opacity="0.31"/>
|
||||||
|
<circle cx="946.5" cy="282.1" r="0.86" opacity="0.82"/>
|
||||||
|
<circle cx="1129.2" cy="181.1" r="1.15" opacity="0.25"/>
|
||||||
|
<circle cx="49.5" cy="288.8" r="0.91" opacity="0.65"/>
|
||||||
|
<circle cx="328.9" cy="247.1" r="1.38" opacity="0.38"/>
|
||||||
|
<circle cx="224.6" cy="216.1" r="0.69" opacity="0.33"/>
|
||||||
|
<circle cx="716.0" cy="255.7" r="1.40" opacity="0.37"/>
|
||||||
|
<circle cx="1174.2" cy="61.2" r="0.62" opacity="0.36"/>
|
||||||
|
<circle cx="570.5" cy="18.1" r="0.83" opacity="0.43"/>
|
||||||
|
<circle cx="732.4" cy="39.5" r="1.07" opacity="0.78"/>
|
||||||
|
<circle cx="1255.0" cy="197.1" r="1.50" opacity="0.57"/>
|
||||||
|
<circle cx="179.6" cy="10.5" r="0.62" opacity="0.79"/>
|
||||||
|
<circle cx="897.2" cy="288.8" r="0.63" opacity="0.61"/>
|
||||||
|
<circle cx="617.3" cy="219.1" r="1.01" opacity="0.85"/>
|
||||||
|
<circle cx="96.3" cy="163.8" r="1.56" opacity="0.78"/>
|
||||||
|
<circle cx="943.5" cy="211.1" r="1.63" opacity="0.79"/>
|
||||||
|
<circle cx="450.3" cy="205.5" r="1.77" opacity="0.76"/>
|
||||||
|
<circle cx="534.0" cy="237.2" r="1.72" opacity="0.56"/>
|
||||||
|
<circle cx="799.9" cy="114.7" r="1.36" opacity="0.59"/>
|
||||||
|
<circle cx="102.7" cy="191.8" r="1.89" opacity="0.77"/>
|
||||||
|
<circle cx="932.1" cy="116.5" r="1.56" opacity="0.57"/>
|
||||||
|
<circle cx="563.9" cy="251.5" r="0.71" opacity="0.68"/>
|
||||||
|
<circle cx="38.1" cy="180.4" r="1.23" opacity="0.33"/>
|
||||||
|
<circle cx="893.9" cy="149.2" r="1.40" opacity="0.80"/>
|
||||||
|
<circle cx="327.5" cy="3.4" r="0.99" opacity="0.63"/>
|
||||||
|
<circle cx="259.3" cy="50.9" r="1.78" opacity="0.62"/>
|
||||||
|
<circle cx="565.7" cy="267.5" r="1.03" opacity="0.63"/>
|
||||||
|
<circle cx="254.1" cy="129.3" r="1.65" opacity="0.79"/>
|
||||||
|
<circle cx="1126.7" cy="115.3" r="1.36" opacity="0.39"/>
|
||||||
|
<circle cx="174.3" cy="148.9" r="1.69" opacity="0.75"/>
|
||||||
|
<circle cx="910.4" cy="285.0" r="0.96" opacity="0.29"/>
|
||||||
|
<circle cx="576.8" cy="82.5" r="0.88" opacity="0.46"/>
|
||||||
|
<circle cx="800.9" cy="148.2" r="1.01" opacity="0.74"/>
|
||||||
|
<circle cx="1257.0" cy="135.7" r="0.70" opacity="0.20"/>
|
||||||
|
<circle cx="1117.2" cy="12.4" r="1.52" opacity="0.56"/>
|
||||||
|
<circle cx="395.6" cy="237.5" r="0.62" opacity="0.27"/>
|
||||||
|
<circle cx="582.2" cy="7.4" r="1.68" opacity="0.34"/>
|
||||||
|
<circle cx="180.3" cy="14.1" r="1.42" opacity="0.48"/>
|
||||||
|
<circle cx="806.4" cy="196.5" r="1.65" opacity="0.82"/>
|
||||||
|
<circle cx="876.2" cy="59.8" r="1.22" opacity="0.30"/>
|
||||||
|
<circle cx="13.8" cy="141.7" r="1.53" opacity="0.30"/>
|
||||||
|
<circle cx="348.6" cy="103.7" r="1.51" opacity="0.53"/>
|
||||||
|
<circle cx="786.5" cy="226.9" r="1.11" opacity="0.71"/>
|
||||||
|
<circle cx="1160.0" cy="26.2" r="1.81" opacity="0.66"/>
|
||||||
|
<circle cx="166.3" cy="136.1" r="1.41" opacity="0.79"/>
|
||||||
|
<circle cx="482.3" cy="170.6" r="1.74" opacity="0.71"/>
|
||||||
|
<circle cx="1208.7" cy="139.1" r="1.45" opacity="0.32"/>
|
||||||
|
<circle cx="924.1" cy="245.5" r="1.43" opacity="0.66"/>
|
||||||
|
<circle cx="273.0" cy="270.0" r="1.87" opacity="0.83"/>
|
||||||
|
<circle cx="687.3" cy="237.2" r="1.02" opacity="0.79"/>
|
||||||
|
<circle cx="1095.4" cy="104.6" r="0.71" opacity="0.48"/>
|
||||||
|
<circle cx="704.4" cy="230.5" r="1.23" opacity="0.20"/>
|
||||||
|
<circle cx="1035.7" cy="19.2" r="1.64" opacity="0.30"/>
|
||||||
|
<circle cx="428.8" cy="236.4" r="0.78" opacity="0.28"/>
|
||||||
|
<circle cx="661.2" cy="217.1" r="1.69" opacity="0.64"/>
|
||||||
|
<circle cx="1210.6" cy="147.8" r="1.83" opacity="0.24"/>
|
||||||
|
<circle cx="283.4" cy="158.0" r="0.98" opacity="0.67"/>
|
||||||
|
<circle cx="817.8" cy="156.8" r="1.70" opacity="0.56"/>
|
||||||
|
<circle cx="399.0" cy="114.4" r="1.70" opacity="0.78"/>
|
||||||
|
<circle cx="266.5" cy="255.2" r="1.86" opacity="0.53"/>
|
||||||
|
<circle cx="733.4" cy="60.3" r="1.30" opacity="0.52"/>
|
||||||
|
<circle cx="774.7" cy="8.3" r="1.86" opacity="0.53"/>
|
||||||
|
<circle cx="512.7" cy="240.3" r="1.33" opacity="0.51"/>
|
||||||
|
<circle cx="884.5" cy="19.8" r="1.30" opacity="0.46"/>
|
||||||
|
<circle cx="1224.8" cy="277.0" r="0.95" opacity="0.50"/>
|
||||||
|
<circle cx="162.5" cy="130.1" r="1.66" opacity="0.78"/>
|
||||||
|
<circle cx="610.0" cy="95.2" r="0.85" opacity="0.59"/>
|
||||||
|
<circle cx="1184.3" cy="38.8" r="1.61" opacity="0.20"/>
|
||||||
|
<circle cx="248.5" cy="68.2" r="1.49" opacity="0.40"/>
|
||||||
|
<circle cx="454.8" cy="185.9" r="0.74" opacity="0.67"/>
|
||||||
|
<circle cx="157.2" cy="153.1" r="0.93" opacity="0.31"/>
|
||||||
|
<circle cx="678.9" cy="131.0" r="1.09" opacity="0.46"/>
|
||||||
|
<circle cx="677.6" cy="47.9" r="0.87" opacity="0.60"/>
|
||||||
|
<circle cx="817.2" cy="158.9" r="1.71" opacity="0.59"/>
|
||||||
|
<circle cx="1096.7" cy="69.8" r="1.56" opacity="0.72"/>
|
||||||
|
<circle cx="1155.4" cy="94.8" r="1.01" opacity="0.80"/>
|
||||||
|
<circle cx="279.2" cy="299.5" r="1.75" opacity="0.27"/>
|
||||||
|
<circle cx="306.4" cy="218.0" r="0.94" opacity="0.25"/>
|
||||||
|
<circle cx="1065.2" cy="126.5" r="1.63" opacity="0.26"/>
|
||||||
|
<circle cx="515.6" cy="205.6" r="0.62" opacity="0.31"/>
|
||||||
|
<circle cx="873.5" cy="273.4" r="1.86" opacity="0.26"/>
|
||||||
|
<circle cx="647.3" cy="227.4" r="1.25" opacity="0.64"/>
|
||||||
|
<circle cx="241.9" cy="21.2" r="0.74" opacity="0.21"/>
|
||||||
|
<circle cx="706.1" cy="154.4" r="1.34" opacity="0.28"/>
|
||||||
|
<circle cx="236.2" cy="61.2" r="1.69" opacity="0.84"/>
|
||||||
|
<circle cx="1186.4" cy="28.6" r="0.68" opacity="0.82"/>
|
||||||
|
<circle cx="591.5" cy="229.4" r="1.02" opacity="0.49"/>
|
||||||
|
<circle cx="659.6" cy="129.0" r="1.38" opacity="0.19"/>
|
||||||
|
<circle cx="897.3" cy="253.3" r="0.84" opacity="0.48"/>
|
||||||
|
<circle cx="946.3" cy="121.6" r="0.85" opacity="0.29"/>
|
||||||
|
<circle cx="656.1" cy="4.6" r="1.76" opacity="0.72"/>
|
||||||
|
<circle cx="902.0" cy="258.2" r="1.42" opacity="0.45"/>
|
||||||
|
<circle cx="767.5" cy="151.3" r="1.88" opacity="0.72"/>
|
||||||
|
<circle cx="330.6" cy="273.4" r="1.57" opacity="0.70"/>
|
||||||
|
<circle cx="1042.7" cy="121.7" r="1.77" opacity="0.77"/>
|
||||||
|
<circle cx="889.3" cy="230.2" r="1.59" opacity="0.45"/>
|
||||||
|
<circle cx="925.0" cy="21.2" r="1.04" opacity="0.49"/>
|
||||||
|
<circle cx="13.6" cy="106.7" r="1.43" opacity="0.60"/>
|
||||||
|
<circle cx="297.1" cy="283.4" r="1.47" opacity="0.41"/>
|
||||||
|
<circle cx="844.5" cy="170.9" r="1.29" opacity="0.44"/>
|
||||||
|
<circle cx="1279.9" cy="192.7" r="1.51" opacity="0.69"/>
|
||||||
|
<circle cx="1254.5" cy="6.8" r="1.40" opacity="0.67"/>
|
||||||
|
<circle cx="328.5" cy="120.5" r="0.67" opacity="0.31"/>
|
||||||
|
</g>
|
||||||
|
<rect y="180" width="1280" height="240" fill="url(#b-horizon)"/>
|
||||||
|
<rect width="1280" height="420" fill="url(#b-glow)"/>
|
||||||
|
<g transform="translate(120 90) rotate(-18) scale(0.42) translate(-32 -32)" opacity="0.16"><path d="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" fill="#E0B252"/></g>
|
||||||
|
<g transform="translate(250 250) rotate(24) scale(0.3) translate(-32 -32)" opacity="0.17"><path d="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" fill="#E0B252"/></g>
|
||||||
|
<g transform="translate(1035 95) rotate(12) scale(0.36) translate(-32 -32)" opacity="0.17"><path d="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" fill="#E0B252"/></g>
|
||||||
|
<g transform="translate(1160 215) rotate(-32) scale(0.46) translate(-32 -32)" opacity="0.18"><path d="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" fill="#E0B252"/></g>
|
||||||
|
<g transform="translate(905 300) rotate(40) scale(0.26) translate(-32 -32)" opacity="0.17"><path d="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" fill="#E0B252"/></g>
|
||||||
|
<g transform="translate(185 300) rotate(-8) scale(0.24) translate(-32 -32)" opacity="0.18"><path d="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" fill="#E0B252"/></g>
|
||||||
|
|
||||||
|
<!-- Ridge lines, furthest first. Each is lighter than the one in front of it,
|
||||||
|
which is what reads as distance. -->
|
||||||
|
<polygon points="0.0,366.0 77.4,260.4 99.7,287.8 209.3,307.1 222.5,340.5 301.6,290.7 339.9,314.6 467.1,267.1 496.3,282.9 606.7,228.9 632.9,259.8 746.4,307.3 778.6,334.3 861.2,310.5 896.2,334.5 1013.6,227.8 1044.7,263.3 1135.1,235.4 1159.3,271.3 1280.0,304.0 1280.0,366.0 1280,999 0,999" fill="#1C2836"/>
|
||||||
|
<polygon points="0.0,392.0 76.5,282.7 92.5,305.1 157.2,334.8 195.6,347.7 306.6,319.4 331.0,337.8 404.6,292.3 419.7,305.8 478.9,333.4 502.2,359.5 591.3,344.5 610.7,372.4 673.6,307.7 696.0,329.2 781.8,302.5 807.3,323.8 939.9,310.5 956.3,320.6 1092.6,317.2 1110.4,344.2 1216.2,299.7 1251.5,314.1 1280.0,288.9 1280.0,392.0 1280,999 0,999" fill="#111A25"/>
|
||||||
|
<polygon points="0.0,416.0 71.3,356.9 100.4,368.9 176.0,352.0 209.4,364.3 309.1,378.7 321.9,389.3 428.2,386.8 461.4,395.6 538.3,388.1 576.7,403.3 707.1,360.5 720.7,370.5 819.7,384.5 856.9,395.1 927.4,350.8 943.4,368.4 1038.7,363.5 1060.3,375.6 1133.4,388.3 1168.4,397.2 1280.0,380.1 1280.0,416.0 1280,999 0,999" fill="#080D13"/>
|
||||||
|
<rect y="415" width="1280" height="5" fill="#C9A227" 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(304.75 118.00) scale(2.1250)">
|
||||||
|
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#b-wafer)"/>
|
||||||
|
<g clip-path="url(#b-clip)" fill="none" stroke-linecap="round">
|
||||||
|
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
|
||||||
|
<path d="M32 6 V58"/>
|
||||||
|
<path d="M6 32 H58"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#F6E3A8" 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="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
|
||||||
|
<g>
|
||||||
|
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
|
||||||
|
stroke-linecap="round" fill="none"/>
|
||||||
|
<path d="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" fill="url(#b-leaf)"/>
|
||||||
|
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5"
|
||||||
|
stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<g fill="none" stroke="#61758A" stroke-opacity="0.32"
|
||||||
|
stroke-width="1" stroke-linecap="round">
|
||||||
|
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/>
|
||||||
|
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/>
|
||||||
|
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/>
|
||||||
|
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/>
|
||||||
|
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/>
|
||||||
|
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(470.08 232.00)">
|
||||||
|
<style>.base { fill: #EDE6D6; } .accent { fill: #E0B252; }</style>
|
||||||
|
<path class="accent" data-char="L" d="M4.67 -88.57 14.83 -87.33C15.24 -76.48 15.24 -61.52 15.24 -49.02V-42.98C15.24 -30.21 15.24 -14.83 14.83 -3.84L4.67 -2.61V0.00H65.91L67.56 -26.78H64.95L56.30 -3.43H29.93C29.52 -14.28 29.39 -29.93 29.39 -42.98V-49.02C29.39 -61.52 29.52 -76.48 29.93 -87.33L39.96 -88.57V-91.18H4.67Z"/>
|
||||||
|
<path class="accent" data-char="L" d="M75.52 -88.57 85.68 -87.33C86.10 -76.48 86.10 -61.52 86.10 -49.02V-42.98C86.10 -30.21 86.10 -14.83 85.68 -3.84L75.52 -2.61V0.00H136.76L138.41 -26.78H135.80L127.15 -3.43H100.79C100.38 -14.28 100.24 -29.93 100.24 -42.98V-49.02C100.24 -61.52 100.38 -76.48 100.79 -87.33L110.81 -88.57V-91.18H75.52Z"/>
|
||||||
|
<path class="base" data-char="e" d="M175.76 -60.97C182.76 -60.97 187.43 -55.47 187.43 -45.18C187.43 -39.13 185.37 -37.07 179.06 -37.07H161.07C162.03 -54.65 168.76 -60.97 175.76 -60.97ZM175.90 1.79C186.20 1.79 194.85 -2.88 199.79 -13.46L197.87 -14.83C193.75 -9.75 188.39 -6.45 180.98 -6.45C169.44 -6.45 160.93 -16.20 160.93 -32.82V-33.92H198.69C199.24 -35.84 199.52 -37.49 199.52 -40.51C199.52 -54.79 189.63 -64.26 175.62 -64.26C160.38 -64.26 146.93 -51.49 146.93 -30.07C146.93 -9.89 159.70 1.79 175.90 1.79Z"/>
|
||||||
|
<path class="accent" data-char="M" d="M209.95 0.00H235.63V-2.61L224.64 -3.84V-80.33L253.21 0.00H258.29L286.85 -81.29V-42.98C286.85 -30.21 286.71 -14.69 286.30 -3.84L276.96 -2.61V0.00H311.56V-2.61L301.54 -3.84C301.13 -14.69 300.99 -30.21 300.99 -42.98V-49.02C300.99 -61.52 301.13 -76.48 301.54 -87.33L311.56 -88.57V-91.18H286.30L261.03 -20.32L236.04 -91.18H209.95V-88.57L220.66 -87.19V-3.84L209.95 -2.61Z"/>
|
||||||
|
<path class="base" data-char="b" d="M320.76 0.00 343.01 1.51V-8.51C347.68 -1.10 353.86 1.79 360.59 1.79C375.28 1.79 386.26 -10.99 386.26 -32.82C386.26 -53.55 375.69 -64.26 361.96 -64.26C353.58 -64.26 347.13 -59.59 343.01 -52.59V-72.50L343.56 -99.96L342.05 -101.34L320.21 -95.57V-93.37L329.69 -91.18V-28.56C329.69 -21.42 329.55 -11.40 329.28 -3.84L320.76 -2.61ZM356.19 -57.26C365.25 -57.26 372.12 -49.84 372.12 -31.99C372.12 -13.73 365.12 -5.36 356.47 -5.36C350.97 -5.36 347.40 -7.00 343.01 -11.67V-49.57C346.72 -54.24 351.11 -57.26 356.19 -57.26Z"/>
|
||||||
|
<path class="base" data-char="a" d="M442.97 1.51C449.15 1.51 453.00 -1.92 455.06 -6.87L453.41 -8.24C451.21 -5.77 449.98 -4.81 447.92 -4.81C445.58 -4.81 444.21 -6.32 444.21 -10.71V-41.33C444.21 -57.26 437.89 -64.26 423.89 -64.26C410.02 -64.26 400.13 -57.81 398.62 -48.47C399.31 -44.76 401.09 -42.70 404.80 -42.70C408.51 -42.70 411.67 -45.18 412.35 -51.08L413.86 -59.73C415.65 -60.28 417.30 -60.56 419.22 -60.56C427.59 -60.56 430.75 -56.30 430.75 -42.84V-38.04C425.53 -36.80 421.14 -35.56 418.12 -34.47C400.96 -28.97 396.29 -22.24 396.29 -13.73C396.29 -3.71 403.29 1.79 412.49 1.79C419.63 1.79 425.40 -0.82 430.89 -7.96C431.85 -1.92 435.83 1.51 442.97 1.51ZM409.47 -16.89C409.47 -22.93 412.21 -27.87 421.55 -31.99C423.34 -32.82 426.50 -33.92 430.75 -35.29V-10.71C426.22 -7.00 423.20 -6.18 419.08 -6.18C413.59 -6.18 409.47 -9.34 409.47 -16.89Z"/>
|
||||||
|
<path class="base" data-char="s" d="M481.56 1.79C496.80 1.79 505.18 -5.90 505.18 -17.85C505.18 -28.97 496.94 -33.09 488.84 -36.53L484.99 -38.17C478.26 -41.06 472.50 -44.08 472.50 -50.94C472.50 -56.99 476.89 -61.24 484.58 -61.24C488.01 -61.24 490.76 -60.83 493.23 -59.59L499.13 -43.53H501.47L502.02 -59.46C496.66 -62.61 491.44 -64.26 484.58 -64.26C469.89 -64.26 462.47 -56.02 462.47 -45.31C462.47 -34.47 470.30 -30.21 478.13 -26.91L481.97 -25.27C488.70 -22.38 494.60 -19.50 494.60 -12.50C494.60 -5.90 489.93 -1.10 481.56 -1.10C477.30 -1.10 474.28 -1.65 471.26 -2.75L465.08 -20.46H462.88L462.06 -3.43C468.24 0.00 473.87 1.79 481.56 1.79Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(351.54 300.00)">
|
||||||
|
<style>.tag { fill: #9AA7B4; }</style>
|
||||||
|
<path class="tag" data-char="W" d="M7.61 0.23H8.46L19.17 -22.00L21.34 0.23H22.20L33.80 -25.03L36.56 -25.38L36.71 -26.00H29.07L28.95 -25.38L32.56 -25.03L23.56 -5.01L21.58 -25.03L24.87 -25.38L24.99 -26.00H16.45L16.34 -25.38L19.56 -25.03L9.86 -4.97L8.58 -25.03L12.15 -25.38L12.26 -26.00H3.34L3.22 -25.38L5.78 -25.03Z"/>
|
||||||
|
<path class="tag" data-char="a" d="M37.72 -5.12C37.72 -7.68 38.77 -11.64 40.82 -14.09C41.91 -15.41 43.31 -16.30 44.94 -16.30C46.06 -16.30 46.92 -15.83 47.58 -15.25L45.64 -5.63C43.04 -2.64 41.21 -1.44 39.85 -1.44C38.22 -1.44 37.72 -2.91 37.72 -5.12ZM46.96 0.47C49.28 0.47 50.84 -1.71 52.04 -3.69L51.57 -4.07C50.21 -2.52 48.93 -1.51 48.00 -1.51C47.61 -1.51 47.38 -1.75 47.38 -2.13C47.38 -2.68 47.50 -3.14 47.65 -3.96L50.53 -18.01L50.21 -18.32L48.47 -16.96C47.69 -17.62 46.72 -18.01 45.79 -18.01C41.02 -18.01 35.20 -9.93 35.20 -4.19C35.20 -0.93 36.75 0.47 38.61 0.47C41.02 0.47 43.27 -1.63 45.40 -4.54C44.90 -2.25 44.90 -1.79 44.90 -1.40C44.90 -0.19 45.87 0.47 46.96 0.47Z"/>
|
||||||
|
<path class="tag" data-char="y" d="M50.87 10.05C51.69 10.05 52.54 9.86 53.47 9.31C56.97 7.30 59.80 3.14 61.78 0.00C63.72 -3.10 65.23 -5.90 66.55 -8.34C67.95 -10.83 68.73 -12.30 69.46 -13.89C69.73 -14.55 70.32 -15.76 70.32 -16.76C70.32 -17.50 70.04 -18.16 69.15 -18.16C68.03 -18.16 67.56 -17.39 66.98 -14.47C65.85 -8.89 64.26 -5.70 61.39 -0.85C61.31 -5.32 60.81 -11.76 60.34 -15.02C60.03 -17.11 59.33 -18.01 57.82 -18.01C56.04 -18.01 54.95 -16.45 53.71 -13.50L54.17 -13.16C55.41 -15.13 56.07 -16.03 56.85 -16.03C57.36 -16.03 57.74 -15.56 57.94 -13.93C58.40 -10.17 59.06 -3.61 59.33 2.17C57.78 4.46 56.07 6.52 53.82 8.11C53.47 8.38 53.05 8.69 52.66 8.89L52.16 8.34C51.34 7.41 50.49 6.91 49.48 6.91C48.62 6.91 47.73 7.37 47.58 8.19C47.93 9.51 49.32 10.05 50.87 10.05Z"/>
|
||||||
|
<path class="tag" data-char="b" d="M75.40 -4.35C75.40 -6.09 76.02 -8.11 76.18 -8.93L76.80 -11.91C79.44 -14.90 81.34 -16.10 82.73 -16.10C84.36 -16.10 85.02 -14.79 85.02 -12.42C85.02 -9.82 83.94 -5.86 81.88 -3.41C80.79 -2.13 79.47 -1.28 77.84 -1.28C75.94 -1.28 75.40 -2.72 75.40 -4.35ZM76.76 0.47C81.80 0.47 87.55 -7.61 87.55 -13.35C87.55 -16.61 85.84 -18.01 83.94 -18.01C81.57 -18.01 79.20 -15.91 76.99 -12.96L80.21 -28.56L79.82 -28.87L74.08 -27.24L74.00 -26.70L77.30 -26.12L73.61 -8.34C73.30 -6.91 72.92 -5.36 72.92 -4.00C72.92 -1.40 74.04 0.47 76.76 0.47Z"/>
|
||||||
|
<path class="tag" data-char="r" d="M90.92 0.00 91.23 0.31 93.56 0.00C93.95 -2.64 94.38 -5.20 94.88 -7.76L95.39 -10.28C96.70 -12.81 98.14 -14.94 99.54 -16.10C100.35 -15.25 101.09 -14.82 101.90 -14.82C103.11 -14.82 103.88 -15.64 103.92 -16.69C103.57 -17.73 102.68 -18.01 101.75 -18.01C99.69 -18.01 97.79 -16.10 95.58 -11.84L96.78 -17.70L96.43 -18.01L90.84 -16.38L90.77 -15.83L94.10 -15.29Z"/>
|
||||||
|
<path class="tag" data-char="e" d="M114.09 -17.23C115.29 -17.23 115.80 -16.22 115.80 -15.06C115.80 -12.46 113.70 -9.74 107.18 -7.80C107.88 -13.19 111.64 -17.23 114.09 -17.23ZM109.94 0.47C112.89 0.47 115.10 -1.40 116.57 -4.00L116.11 -4.31C115.02 -3.07 113.04 -1.47 111.10 -1.47C108.89 -1.47 107.07 -2.95 107.07 -5.98C107.07 -6.33 107.07 -6.67 107.10 -7.02C116.07 -9.55 118.05 -12.42 118.05 -15.06C118.05 -17.04 116.69 -18.01 114.36 -18.01C109.94 -18.01 104.62 -12.19 104.62 -5.32C104.62 -1.55 106.79 0.47 109.94 0.47Z"/>
|
||||||
|
<path class="tag" data-char="a" d="M122.12 -5.12C122.12 -7.68 123.17 -11.64 125.23 -14.09C126.31 -15.41 127.71 -16.30 129.34 -16.30C130.47 -16.30 131.32 -15.83 131.98 -15.25L130.04 -5.63C127.44 -2.64 125.61 -1.44 124.26 -1.44C122.63 -1.44 122.12 -2.91 122.12 -5.12ZM131.36 0.47C133.69 0.47 135.24 -1.71 136.44 -3.69L135.98 -4.07C134.62 -2.52 133.34 -1.51 132.41 -1.51C132.02 -1.51 131.79 -1.75 131.79 -2.13C131.79 -2.68 131.90 -3.14 132.06 -3.96L134.93 -18.01L134.62 -18.32L132.87 -16.96C132.10 -17.62 131.13 -18.01 130.19 -18.01C125.42 -18.01 119.60 -9.93 119.60 -4.19C119.60 -0.93 121.15 0.47 123.01 0.47C125.42 0.47 127.67 -1.63 129.81 -4.54C129.30 -2.25 129.30 -1.79 129.30 -1.40C129.30 -0.19 130.27 0.47 131.36 0.47Z"/>
|
||||||
|
<path class="tag" data-char="d" d="M141.41 -5.12C141.41 -7.92 142.69 -12.30 145.02 -14.67C146.03 -15.64 147.27 -16.30 148.63 -16.30C149.75 -16.30 150.61 -15.83 151.27 -15.25L149.29 -5.51C146.69 -2.60 144.90 -1.44 143.54 -1.44C141.91 -1.44 141.41 -2.91 141.41 -5.12ZM150.64 0.47C152.97 0.47 154.53 -1.71 155.73 -3.69L155.26 -4.07C153.90 -2.52 152.62 -1.51 151.69 -1.51C151.30 -1.51 151.07 -1.75 151.07 -2.13C151.07 -2.68 151.19 -3.14 151.34 -3.96L156.43 -28.56L156.08 -28.87L150.37 -27.24L150.26 -26.70L153.59 -26.12L151.77 -17.27C151.07 -17.73 150.26 -18.01 149.48 -18.01C144.71 -18.01 138.89 -9.93 138.89 -4.19C138.89 -0.93 140.44 0.47 142.30 0.47C144.71 0.47 146.96 -1.63 149.05 -4.50L148.90 -3.65C148.63 -2.33 148.59 -1.82 148.59 -1.36C148.59 -0.19 149.56 0.47 150.64 0.47Z"/>
|
||||||
|
<path class="tag" data-char="f" d="M161.32 10.05C163.22 10.05 164.81 9.08 166.09 7.57C167.95 5.32 169.16 2.02 169.62 -0.97C170.44 -6.17 171.25 -11.41 172.07 -16.61H176.99L177.19 -17.54H172.22C172.30 -17.97 172.34 -18.39 172.41 -18.82C173.35 -24.84 175.29 -27.63 177.30 -28.60L178.70 -27.01C179.48 -26.08 180.02 -25.61 180.84 -25.61C181.42 -25.61 182.04 -25.88 182.19 -26.74C181.96 -28.21 180.06 -29.26 178.12 -29.26C175.67 -29.26 171.33 -27.13 170.01 -18.74C169.97 -18.39 169.89 -18.01 169.85 -17.66L166.24 -17.23V-16.61H169.70C168.88 -11.37 168.07 -6.17 167.25 -0.97C166.90 1.16 166.32 4.42 165.04 6.75C164.54 7.64 163.92 8.42 163.14 8.93L162.56 8.34C161.67 7.45 161.04 6.91 159.88 6.91C159.03 6.91 158.17 7.37 158.02 8.19C158.37 9.51 159.73 10.05 161.32 10.05Z"/>
|
||||||
|
<path class="tag" data-char="o" d="M182.35 0.47C187.51 0.47 191.27 -5.12 191.27 -11.33C191.27 -15.79 188.64 -18.01 185.45 -18.01C180.33 -18.01 176.57 -12.42 176.57 -6.21C176.57 -1.75 179.17 0.47 182.35 0.47ZM182.35 -0.31C180.10 -0.31 179.09 -2.29 179.09 -5.98C179.09 -12.19 182.08 -17.23 185.45 -17.23C187.70 -17.23 188.75 -15.25 188.75 -11.60C188.75 -5.36 185.73 -0.31 182.35 -0.31Z"/>
|
||||||
|
<path class="tag" data-char="r" d="M194.77 0.00 195.08 0.31 197.41 0.00C197.79 -2.64 198.22 -5.20 198.73 -7.76L199.23 -10.28C200.55 -12.81 201.99 -14.94 203.38 -16.10C204.20 -15.25 204.93 -14.82 205.75 -14.82C206.95 -14.82 207.73 -15.64 207.77 -16.69C207.42 -17.73 206.53 -18.01 205.59 -18.01C203.54 -18.01 201.64 -16.10 199.42 -11.84L200.63 -17.70L200.28 -18.01L194.69 -16.38L194.61 -15.83L197.95 -15.29Z"/>
|
||||||
|
<path class="tag" data-char="t" d="M219.18 0.47C221.54 0.47 223.29 -1.71 224.49 -3.69L224.03 -4.07C222.71 -2.52 221.39 -1.51 220.46 -1.51C220.07 -1.51 219.84 -1.75 219.84 -2.13C219.84 -2.68 219.99 -3.14 220.15 -3.96L222.79 -16.61H227.29L227.48 -17.54H222.98L224.22 -23.44H223.52L220.77 -17.70L216.85 -17.23V-16.61H220.42L217.74 -3.73C217.47 -2.37 217.35 -1.82 217.35 -1.36C217.35 -0.19 218.13 0.47 219.18 0.47Z"/>
|
||||||
|
<path class="tag" data-char="h" d="M228.53 0.31 230.86 0.00C231.24 -2.64 231.63 -5.20 232.18 -7.76L233.22 -12.84C235.82 -14.94 237.73 -15.95 239.16 -15.95C239.94 -15.95 240.56 -15.44 240.56 -14.44C240.56 -13.54 240.17 -11.99 239.90 -10.79L238.23 -3.61C237.92 -2.25 237.92 -1.71 237.92 -1.24C237.92 -0.08 238.93 0.47 239.86 0.47C242.27 0.47 243.90 -1.71 245.10 -3.69L244.63 -4.07C243.27 -2.52 241.88 -1.51 241.14 -1.51C240.79 -1.51 240.44 -1.79 240.44 -2.25C240.44 -2.64 240.56 -3.34 240.75 -4.15L242.50 -11.84C242.77 -13.00 243.04 -14.20 243.04 -15.37C243.04 -17.07 242.19 -18.01 240.67 -18.01C238.46 -18.01 235.75 -16.03 233.42 -13.74L236.52 -28.56L236.21 -28.87L230.39 -27.24L230.31 -26.70L233.50 -26.16C233.22 -24.37 232.87 -22.51 232.49 -20.68L228.22 0.00Z"/>
|
||||||
|
<path class="tag" data-char="e" d="M256.86 -17.23C258.06 -17.23 258.56 -16.22 258.56 -15.06C258.56 -12.46 256.47 -9.74 249.95 -7.80C250.65 -13.19 254.41 -17.23 256.86 -17.23ZM252.70 0.47C255.65 0.47 257.87 -1.40 259.34 -4.00L258.87 -4.31C257.79 -3.07 255.81 -1.47 253.87 -1.47C251.66 -1.47 249.83 -2.95 249.83 -5.98C249.83 -6.33 249.83 -6.67 249.87 -7.02C258.84 -9.55 260.81 -12.42 260.81 -15.06C260.81 -17.04 259.46 -18.01 257.13 -18.01C252.70 -18.01 247.39 -12.19 247.39 -5.32C247.39 -1.55 249.56 0.47 252.70 0.47Z"/>
|
||||||
|
<path class="tag" data-char="l" d="M272.84 0.47C275.29 0.47 277.04 -1.71 278.24 -3.69L277.77 -4.07C276.41 -2.52 275.06 -1.51 274.28 -1.51C273.93 -1.51 273.58 -1.79 273.58 -2.25C273.58 -2.64 273.74 -3.34 273.89 -4.15L278.94 -28.56L278.63 -28.87L272.88 -27.24L272.81 -26.70L275.91 -26.16C275.64 -24.37 275.29 -22.51 274.90 -20.68L271.37 -3.61C271.10 -2.25 271.06 -1.71 271.06 -1.24C271.06 -0.08 271.91 0.47 272.84 0.47Z"/>
|
||||||
|
<path class="tag" data-char="o" d="M286.50 0.47C291.67 0.47 295.43 -5.12 295.43 -11.33C295.43 -15.79 292.79 -18.01 289.61 -18.01C284.49 -18.01 280.72 -12.42 280.72 -6.21C280.72 -1.75 283.32 0.47 286.50 0.47ZM286.50 -0.31C284.25 -0.31 283.24 -2.29 283.24 -5.98C283.24 -12.19 286.23 -17.23 289.61 -17.23C291.86 -17.23 292.91 -15.25 292.91 -11.60C292.91 -5.36 289.88 -0.31 286.50 -0.31Z"/>
|
||||||
|
<path class="tag" data-char="n" d="M299.23 0.31 301.56 0.00C301.95 -2.64 302.34 -5.20 302.88 -7.76L303.89 -12.84C306.53 -14.94 308.43 -15.95 309.87 -15.95C310.60 -15.95 311.22 -15.44 311.22 -14.44C311.22 -13.54 310.84 -11.99 310.56 -10.79L308.93 -3.61C308.62 -2.25 308.59 -1.71 308.59 -1.24C308.59 -0.08 309.59 0.47 310.53 0.47C312.97 0.47 314.56 -1.71 315.76 -3.69L315.30 -4.07C313.98 -2.52 312.58 -1.51 311.81 -1.51C311.46 -1.51 311.11 -1.79 311.11 -2.25C311.11 -2.64 311.22 -3.34 311.42 -4.15L313.16 -11.84C313.44 -13.00 313.71 -14.20 313.71 -15.37C313.71 -17.07 312.89 -18.01 311.38 -18.01C309.13 -18.01 306.41 -16.03 304.08 -13.70L304.94 -17.70L304.59 -18.01L298.84 -16.38L298.77 -15.83L302.10 -15.29L298.92 0.00Z"/>
|
||||||
|
<path class="tag" data-char="g" d="M324.19 -5.28C327.91 -5.28 330.28 -8.38 330.94 -11.76C331.21 -13.08 331.64 -14.59 332.02 -15.60C334.08 -15.79 335.52 -16.14 335.52 -17.54C335.52 -17.81 335.40 -18.20 335.24 -18.39C335.01 -18.51 334.62 -18.55 334.24 -18.55C332.76 -18.55 331.48 -17.81 330.82 -13.97V-13.66C330.74 -16.73 328.92 -18.16 326.16 -18.16C322.32 -18.16 319.30 -14.51 319.30 -10.01C319.30 -8.07 320.03 -6.75 321.24 -6.01C319.53 -4.73 318.60 -3.49 318.60 -2.17C318.60 -0.97 319.22 -0.19 320.42 0.19C317.82 1.40 315.61 3.34 315.61 5.98C315.61 8.58 317.78 10.05 321.20 10.05C327.29 10.05 331.36 6.40 331.36 2.60C331.36 0.70 330.16 -0.85 326.55 -1.24L322.90 -1.63C321.16 -1.82 320.46 -2.48 320.46 -3.30C320.46 -3.88 320.69 -4.58 321.70 -5.78C322.44 -5.43 323.25 -5.28 324.19 -5.28ZM324.38 -6.01C322.71 -6.01 321.78 -7.45 321.78 -10.44C321.78 -14.55 323.56 -17.46 325.97 -17.46C327.64 -17.46 328.57 -16.18 328.57 -13.16C328.57 -9.08 326.75 -6.01 324.38 -6.01ZM317.82 5.24C317.82 3.34 319.02 1.75 321.12 0.39C321.31 0.43 321.47 0.43 321.62 0.47L325.50 0.89C328.57 1.24 329.19 2.48 329.19 4.19C329.19 6.60 326.71 8.65 322.63 8.65C319.68 8.65 317.82 7.33 317.82 5.24Z"/>
|
||||||
|
<path class="tag" data-char="r" d="M344.48 0.00 344.79 0.31 347.12 0.00C347.51 -2.64 347.93 -5.20 348.44 -7.76L348.94 -10.28C350.26 -12.81 351.70 -14.94 353.10 -16.10C353.91 -15.25 354.65 -14.82 355.46 -14.82C356.67 -14.82 357.44 -15.64 357.48 -16.69C357.13 -17.73 356.24 -18.01 355.31 -18.01C353.25 -18.01 351.35 -16.10 349.14 -11.84L350.34 -17.70L349.99 -18.01L344.40 -16.38L344.33 -15.83L347.66 -15.29Z"/>
|
||||||
|
<path class="tag" data-char="o" d="M364.12 0.47C369.28 0.47 373.04 -5.12 373.04 -11.33C373.04 -15.79 370.40 -18.01 367.22 -18.01C362.10 -18.01 358.33 -12.42 358.33 -6.21C358.33 -1.75 360.93 0.47 364.12 0.47ZM364.12 -0.31C361.87 -0.31 360.86 -2.29 360.86 -5.98C360.86 -12.19 363.84 -17.23 367.22 -17.23C369.47 -17.23 370.52 -15.25 370.52 -11.60C370.52 -5.36 367.49 -0.31 364.12 -0.31Z"/>
|
||||||
|
<path class="tag" data-char="a" d="M378.01 -5.12C378.01 -7.68 379.06 -11.64 381.11 -14.09C382.20 -15.41 383.60 -16.30 385.23 -16.30C386.35 -16.30 387.21 -15.83 387.87 -15.25L385.93 -5.63C383.33 -2.64 381.50 -1.44 380.14 -1.44C378.51 -1.44 378.01 -2.91 378.01 -5.12ZM387.24 0.47C389.57 0.47 391.13 -1.71 392.33 -3.69L391.86 -4.07C390.50 -2.52 389.22 -1.51 388.29 -1.51C387.90 -1.51 387.67 -1.75 387.67 -2.13C387.67 -2.68 387.79 -3.14 387.94 -3.96L390.81 -18.01L390.50 -18.32L388.76 -16.96C387.98 -17.62 387.01 -18.01 386.08 -18.01C381.31 -18.01 375.49 -9.93 375.49 -4.19C375.49 -0.93 377.04 0.47 378.90 0.47C381.31 0.47 383.56 -1.63 385.69 -4.54C385.19 -2.25 385.19 -1.79 385.19 -1.40C385.19 -0.19 386.16 0.47 387.24 0.47Z"/>
|
||||||
|
<path class="tag" data-char="d" d="M397.30 -5.12C397.30 -7.92 398.58 -12.30 400.90 -14.67C401.91 -15.64 403.16 -16.30 404.51 -16.30C405.64 -16.30 406.49 -15.83 407.15 -15.25L405.17 -5.51C402.57 -2.60 400.79 -1.44 399.43 -1.44C397.80 -1.44 397.30 -2.91 397.30 -5.12ZM406.53 0.47C408.86 0.47 410.41 -1.71 411.61 -3.69L411.15 -4.07C409.79 -2.52 408.51 -1.51 407.58 -1.51C407.19 -1.51 406.96 -1.75 406.96 -2.13C406.96 -2.68 407.07 -3.14 407.23 -3.96L412.31 -28.56L411.96 -28.87L406.26 -27.24L406.14 -26.70L409.48 -26.12L407.66 -17.27C406.96 -17.73 406.14 -18.01 405.37 -18.01C400.59 -18.01 394.77 -9.93 394.77 -4.19C394.77 -0.93 396.33 0.47 398.19 0.47C400.59 0.47 402.84 -1.63 404.94 -4.50L404.79 -3.65C404.51 -2.33 404.47 -1.82 404.47 -1.36C404.47 -0.19 405.44 0.47 406.53 0.47Z"/>
|
||||||
|
<path class="tag" data-char="o" d="M427.76 0.47C432.92 0.47 436.68 -5.12 436.68 -11.33C436.68 -15.79 434.04 -18.01 430.86 -18.01C425.74 -18.01 421.98 -12.42 421.98 -6.21C421.98 -1.75 424.58 0.47 427.76 0.47ZM427.76 -0.31C425.51 -0.31 424.50 -2.29 424.50 -5.98C424.50 -12.19 427.49 -17.23 430.86 -17.23C433.11 -17.23 434.16 -15.25 434.16 -11.60C434.16 -5.36 431.13 -0.31 427.76 -0.31Z"/>
|
||||||
|
<path class="tag" data-char="f" d="M434.20 10.05C436.10 10.05 437.69 9.08 438.97 7.57C440.84 5.32 442.04 2.02 442.50 -0.97C443.32 -6.17 444.13 -11.41 444.95 -16.61H449.88L450.07 -17.54H445.10C445.18 -17.97 445.22 -18.39 445.30 -18.82C446.23 -24.84 448.17 -27.63 450.19 -28.60L451.59 -27.01C452.36 -26.08 452.90 -25.61 453.72 -25.61C454.30 -25.61 454.92 -25.88 455.08 -26.74C454.84 -28.21 452.94 -29.26 451.00 -29.26C448.56 -29.26 444.21 -27.13 442.89 -18.74C442.85 -18.39 442.78 -18.01 442.74 -17.66L439.13 -17.23V-16.61H442.58C441.77 -11.37 440.95 -6.17 440.14 -0.97C439.79 1.16 439.21 4.42 437.93 6.75C437.42 7.64 436.80 8.42 436.02 8.93L435.44 8.34C434.55 7.45 433.93 6.91 432.76 6.91C431.91 6.91 431.06 7.37 430.90 8.19C431.25 9.51 432.61 10.05 434.20 10.05Z"/>
|
||||||
|
<path class="tag" data-char="t" d="M460.01 0.47C462.37 0.47 464.12 -1.71 465.32 -3.69L464.86 -4.07C463.54 -2.52 462.22 -1.51 461.29 -1.51C460.90 -1.51 460.67 -1.75 460.67 -2.13C460.67 -2.68 460.82 -3.14 460.98 -3.96L463.61 -16.61H468.12L468.31 -17.54H463.81L465.05 -23.44H464.35L461.60 -17.70L457.68 -17.23V-16.61H461.25L458.57 -3.73C458.30 -2.37 458.18 -1.82 458.18 -1.36C458.18 -0.19 458.96 0.47 460.01 0.47Z"/>
|
||||||
|
<path class="tag" data-char="h" d="M469.36 0.31 471.69 0.00C472.07 -2.64 472.46 -5.20 473.01 -7.76L474.05 -12.84C476.65 -14.94 478.56 -15.95 479.99 -15.95C480.77 -15.95 481.39 -15.44 481.39 -14.44C481.39 -13.54 481.00 -11.99 480.73 -10.79L479.06 -3.61C478.75 -2.25 478.75 -1.71 478.75 -1.24C478.75 -0.08 479.76 0.47 480.69 0.47C483.10 0.47 484.73 -1.71 485.93 -3.69L485.46 -4.07C484.10 -2.52 482.71 -1.51 481.97 -1.51C481.62 -1.51 481.27 -1.79 481.27 -2.25C481.27 -2.64 481.39 -3.34 481.58 -4.15L483.33 -11.84C483.60 -13.00 483.87 -14.20 483.87 -15.37C483.87 -17.07 483.02 -18.01 481.50 -18.01C479.29 -18.01 476.58 -16.03 474.25 -13.74L477.35 -28.56L477.04 -28.87L471.22 -27.24L471.14 -26.70L474.33 -26.16C474.05 -24.37 473.70 -22.51 473.32 -20.68L469.05 0.00Z"/>
|
||||||
|
<path class="tag" data-char="o" d="M494.16 0.47C499.32 0.47 503.08 -5.12 503.08 -11.33C503.08 -15.79 500.44 -18.01 497.26 -18.01C492.14 -18.01 488.37 -12.42 488.37 -6.21C488.37 -1.75 490.97 0.47 494.16 0.47ZM494.16 -0.31C491.90 -0.31 490.90 -2.29 490.90 -5.98C490.90 -12.19 493.88 -17.23 497.26 -17.23C499.51 -17.23 500.56 -15.25 500.56 -11.60C500.56 -5.36 497.53 -0.31 494.16 -0.31Z"/>
|
||||||
|
<path class="tag" data-char="u" d="M509.21 0.47C511.46 0.47 514.14 -1.51 516.47 -3.80C516.16 -2.33 516.12 -1.82 516.12 -1.36C516.12 -0.19 516.90 0.47 517.94 0.47C520.31 0.47 522.10 -1.71 523.30 -3.69L522.79 -4.07C521.47 -2.52 520.16 -1.51 519.26 -1.51C518.87 -1.51 518.64 -1.75 518.64 -2.13C518.64 -2.68 518.76 -3.14 518.91 -3.96L521.75 -17.70L521.40 -18.01L519.03 -17.39C518.64 -14.79 518.21 -12.34 517.71 -9.78L516.66 -4.70C514.06 -2.60 512.16 -1.59 510.76 -1.59C509.99 -1.59 509.41 -2.10 509.41 -3.14C509.41 -4.00 509.79 -5.55 510.07 -6.75L512.43 -17.70L512.12 -18.01L506.38 -16.38L506.26 -15.83L509.60 -15.29L507.43 -5.70C507.16 -4.54 506.88 -3.34 506.88 -2.17C506.88 -0.47 507.70 0.47 509.21 0.47Z"/>
|
||||||
|
<path class="tag" data-char="g" d="M531.68 -5.28C535.41 -5.28 537.77 -8.38 538.43 -11.76C538.70 -13.08 539.13 -14.59 539.52 -15.60C541.58 -15.79 543.01 -16.14 543.01 -17.54C543.01 -17.81 542.90 -18.20 542.74 -18.39C542.51 -18.51 542.12 -18.55 541.73 -18.55C540.26 -18.55 538.98 -17.81 538.32 -13.97V-13.66C538.24 -16.73 536.41 -18.16 533.66 -18.16C529.82 -18.16 526.79 -14.51 526.79 -10.01C526.79 -8.07 527.53 -6.75 528.73 -6.01C527.02 -4.73 526.09 -3.49 526.09 -2.17C526.09 -0.97 526.71 -0.19 527.92 0.19C525.32 1.40 523.10 3.34 523.10 5.98C523.10 8.58 525.28 10.05 528.69 10.05C534.79 10.05 538.86 6.40 538.86 2.60C538.86 0.70 537.66 -0.85 534.05 -1.24L530.40 -1.63C528.65 -1.82 527.96 -2.48 527.96 -3.30C527.96 -3.88 528.19 -4.58 529.20 -5.78C529.93 -5.43 530.75 -5.28 531.68 -5.28ZM531.87 -6.01C530.21 -6.01 529.27 -7.45 529.27 -10.44C529.27 -14.55 531.06 -17.46 533.47 -17.46C535.13 -17.46 536.07 -16.18 536.07 -13.16C536.07 -9.08 534.24 -6.01 531.87 -6.01ZM525.32 5.24C525.32 3.34 526.52 1.75 528.61 0.39C528.81 0.43 528.96 0.43 529.12 0.47L533.00 0.89C536.07 1.24 536.69 2.48 536.69 4.19C536.69 6.60 534.20 8.65 530.13 8.65C527.18 8.65 525.32 7.33 525.32 5.24Z"/>
|
||||||
|
<path class="tag" data-char="h" d="M543.75 0.31 546.08 0.00C546.47 -2.64 546.85 -5.20 547.40 -7.76L548.44 -12.84C551.04 -14.94 552.95 -15.95 554.38 -15.95C555.16 -15.95 555.78 -15.44 555.78 -14.44C555.78 -13.54 555.39 -11.99 555.12 -10.79L553.45 -3.61C553.14 -2.25 553.14 -1.71 553.14 -1.24C553.14 -0.08 554.15 0.47 555.08 0.47C557.49 0.47 559.12 -1.71 560.32 -3.69L559.85 -4.07C558.50 -2.52 557.10 -1.51 556.36 -1.51C556.01 -1.51 555.66 -1.79 555.66 -2.25C555.66 -2.64 555.78 -3.34 555.97 -4.15L557.72 -11.84C557.99 -13.00 558.26 -14.20 558.26 -15.37C558.26 -17.07 557.41 -18.01 555.90 -18.01C553.68 -18.01 550.97 -16.03 548.64 -13.74L551.74 -28.56L551.43 -28.87L545.61 -27.24L545.53 -26.70L548.72 -26.16C548.44 -24.37 548.10 -22.51 547.71 -20.68L543.44 0.00Z"/>
|
||||||
|
<path class="tag" data-char="t" d="M565.40 0.47C567.77 0.47 569.52 -1.71 570.72 -3.69L570.25 -4.07C568.93 -2.52 567.61 -1.51 566.68 -1.51C566.30 -1.51 566.06 -1.75 566.06 -2.13C566.06 -2.68 566.22 -3.14 566.37 -3.96L569.01 -16.61H573.51L573.71 -17.54H569.21L570.45 -23.44H569.75L566.99 -17.70L563.07 -17.23V-16.61H566.64L563.97 -3.73C563.70 -2.37 563.58 -1.82 563.58 -1.36C563.58 -0.19 564.36 0.47 565.40 0.47Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"
|
||||||
|
role="img" aria-label="LLeMbas">
|
||||||
|
<title>LLeMbas</title>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="f-wafer" x1="0" y1="0" x2="0.3" y2="1">
|
||||||
|
<stop offset="0" stop-color="#EACB74"/>
|
||||||
|
<stop offset="0.5" stop-color="#C9A227"/>
|
||||||
|
<stop offset="1" stop-color="#916F13"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="f-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||||
|
<stop offset="0" stop-color="#93A5B6"/>
|
||||||
|
<stop offset="0.4" stop-color="#F1F6FA"/>
|
||||||
|
<stop offset="1" stop-color="#B8C7D5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="f-clip">
|
||||||
|
<rect x="6" y="6" width="52" height="52" rx="13"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<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="#8A9AA8" stroke-width="3.4"
|
||||||
|
stroke-linecap="round" fill="none"/>
|
||||||
|
<path d="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" fill="url(#f-leaf)"/>
|
||||||
|
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.45"
|
||||||
|
stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,49 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" 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>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="m-wafer" x1="0" y1="0" x2="0.3" y2="1">
|
||||||
|
<stop offset="0" stop-color="#EACB74"/>
|
||||||
|
<stop offset="0.5" stop-color="#C9A227"/>
|
||||||
|
<stop offset="1" stop-color="#916F13"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="m-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||||
|
<stop offset="0" stop-color="#93A5B6"/>
|
||||||
|
<stop offset="0.4" stop-color="#F1F6FA"/>
|
||||||
|
<stop offset="1" stop-color="#B8C7D5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="m-clip">
|
||||||
|
<rect x="6" y="6" width="52" height="52" rx="13"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#m-wafer)"/>
|
||||||
|
<g clip-path="url(#m-clip)" fill="none" stroke-linecap="round">
|
||||||
|
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
|
||||||
|
<path d="M32 6 V58"/>
|
||||||
|
<path d="M6 32 H58"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#F6E3A8" 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="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
|
||||||
|
<g>
|
||||||
|
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
|
||||||
|
stroke-linecap="round" fill="none"/>
|
||||||
|
<path d="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" fill="url(#m-leaf)"/>
|
||||||
|
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5"
|
||||||
|
stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<g fill="none" stroke="#61758A" stroke-opacity="0.32"
|
||||||
|
stroke-width="1" stroke-linecap="round">
|
||||||
|
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/>
|
||||||
|
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/>
|
||||||
|
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/>
|
||||||
|
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/>
|
||||||
|
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/>
|
||||||
|
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -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);
|
||||||
|
});
|
||||||
|
})();
|
||||||
+5
File diff suppressed because one or more lines are too long
+290
@@ -0,0 +1,290 @@
|
|||||||
|
/*
|
||||||
|
Server Sent Events Extension
|
||||||
|
============================
|
||||||
|
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
/** @type {import("../htmx").HtmxInternalApi} */
|
||||||
|
var api
|
||||||
|
|
||||||
|
htmx.defineExtension('sse', {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Init saves the provided reference to the internal HTMX API.
|
||||||
|
*
|
||||||
|
* @param {import("../htmx").HtmxInternalApi} api
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
init: function(apiRef) {
|
||||||
|
// store a reference to the internal API.
|
||||||
|
api = apiRef
|
||||||
|
|
||||||
|
// set a function in the public API for creating new EventSource objects
|
||||||
|
if (htmx.createEventSource == undefined) {
|
||||||
|
htmx.createEventSource = createEventSource
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectors: function() {
|
||||||
|
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onEvent handles all events passed to this extension.
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {Event} evt
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
onEvent: function(name, evt) {
|
||||||
|
var parent = evt.target || evt.detail.elt
|
||||||
|
switch (name) {
|
||||||
|
case 'htmx:beforeCleanupElement':
|
||||||
|
var internalData = api.getInternalData(parent)
|
||||||
|
// Try to remove remove an EventSource when elements are removed
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
if (source) {
|
||||||
|
api.triggerEvent(parent, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'nodeReplaced',
|
||||||
|
})
|
||||||
|
internalData.sseEventSource.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
// Try to create EventSources when elements are processed
|
||||||
|
case 'htmx:afterProcessNode':
|
||||||
|
ensureEventSourceOnElement(parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/// ////////////////////////////////////////////
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
/// ////////////////////////////////////////////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createEventSource is the default method for creating new EventSource objects.
|
||||||
|
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @returns EventSource
|
||||||
|
*/
|
||||||
|
function createEventSource(url) {
|
||||||
|
return new EventSource(url, { withCredentials: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* registerSSE looks for attributes that can contain sse events, right
|
||||||
|
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
||||||
|
* the closest event source
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
*/
|
||||||
|
function registerSSE(elt) {
|
||||||
|
// Add message handlers for every `sse-swap` attribute
|
||||||
|
if (api.getAttributeValue(elt, 'sse-swap')) {
|
||||||
|
// Find closest existing event source
|
||||||
|
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||||
|
if (sourceElement == null) {
|
||||||
|
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||||
|
return null // no eventsource in parentage, orphaned element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set internalData and source
|
||||||
|
var internalData = api.getInternalData(sourceElement)
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
|
||||||
|
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
|
||||||
|
var sseEventNames = sseSwapAttr.split(',')
|
||||||
|
|
||||||
|
for (var i = 0; i < sseEventNames.length; i++) {
|
||||||
|
const sseEventName = sseEventNames[i].trim()
|
||||||
|
const listener = function(event) {
|
||||||
|
// If the source is missing then close SSE
|
||||||
|
if (maybeCloseSSESource(sourceElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the body no longer contains the element, remove the listener
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
source.removeEventListener(sseEventName, listener)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// swap the response into the DOM and trigger a notification
|
||||||
|
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
swap(elt, event.data)
|
||||||
|
api.triggerEvent(elt, 'htmx:sseMessage', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the new listener
|
||||||
|
api.getInternalData(elt).sseEventListener = listener
|
||||||
|
source.addEventListener(sseEventName, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
||||||
|
if (api.getAttributeValue(elt, 'hx-trigger')) {
|
||||||
|
// Find closest existing event source
|
||||||
|
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||||
|
if (sourceElement == null) {
|
||||||
|
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||||
|
return null // no eventsource in parentage, orphaned element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set internalData and source
|
||||||
|
var internalData = api.getInternalData(sourceElement)
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
|
||||||
|
var triggerSpecs = api.getTriggerSpecs(elt)
|
||||||
|
triggerSpecs.forEach(function(ts) {
|
||||||
|
if (ts.trigger.slice(0, 4) !== 'sse:') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var listener = function (event) {
|
||||||
|
if (maybeCloseSSESource(sourceElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
source.removeEventListener(ts.trigger.slice(4), listener)
|
||||||
|
}
|
||||||
|
// Trigger events to be handled by the rest of htmx
|
||||||
|
htmx.trigger(elt, ts.trigger, event)
|
||||||
|
htmx.trigger(elt, 'htmx:sseMessage', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the new listener
|
||||||
|
api.getInternalData(elt).sseEventListener = listener
|
||||||
|
source.addEventListener(ts.trigger.slice(4), listener)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
||||||
|
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
||||||
|
* is created and stored in the element's internalData.
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @param {number} retryCount
|
||||||
|
* @returns {EventSource | null}
|
||||||
|
*/
|
||||||
|
function ensureEventSourceOnElement(elt, retryCount) {
|
||||||
|
if (elt == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle extension source creation attribute
|
||||||
|
if (api.getAttributeValue(elt, 'sse-connect')) {
|
||||||
|
var sseURL = api.getAttributeValue(elt, 'sse-connect')
|
||||||
|
if (sseURL == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureEventSource(elt, sseURL, retryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSSE(elt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEventSource(elt, url, retryCount) {
|
||||||
|
var source = htmx.createEventSource(url)
|
||||||
|
|
||||||
|
source.onerror = function(err) {
|
||||||
|
// Log an error event
|
||||||
|
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
|
||||||
|
|
||||||
|
// If parent no longer exists in the document, then clean up this EventSource
|
||||||
|
if (maybeCloseSSESource(elt)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to reconnect the EventSource
|
||||||
|
if (source.readyState === EventSource.CLOSED) {
|
||||||
|
retryCount = retryCount || 0
|
||||||
|
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
|
||||||
|
var timeout = retryCount * 500
|
||||||
|
window.setTimeout(function() {
|
||||||
|
ensureEventSourceOnElement(elt, retryCount)
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onopen = function(evt) {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseOpen', { source })
|
||||||
|
|
||||||
|
if (retryCount && retryCount > 0) {
|
||||||
|
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
|
||||||
|
for (let i = 0; i < childrenToFix.length; i++) {
|
||||||
|
registerSSE(childrenToFix[i])
|
||||||
|
}
|
||||||
|
// We want to increase the reconnection delay for consecutive failed attempts only
|
||||||
|
retryCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getInternalData(elt).sseEventSource = source
|
||||||
|
|
||||||
|
|
||||||
|
var closeAttribute = api.getAttributeValue(elt, "sse-close");
|
||||||
|
if (closeAttribute) {
|
||||||
|
// close eventsource when this message is received
|
||||||
|
source.addEventListener(closeAttribute, function() {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'message',
|
||||||
|
})
|
||||||
|
source.close()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* maybeCloseSSESource confirms that the parent element still exists.
|
||||||
|
* If not, then any associated SSE source is closed and the function returns true.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
function maybeCloseSSESource(elt) {
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
var source = api.getInternalData(elt).sseEventSource
|
||||||
|
if (source != undefined) {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'nodeMissing',
|
||||||
|
})
|
||||||
|
source.close()
|
||||||
|
// source = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @param {string} content
|
||||||
|
*/
|
||||||
|
function swap(elt, content) {
|
||||||
|
api.withExtensions(elt, function(extension) {
|
||||||
|
content = extension.transformResponse(content, null, elt)
|
||||||
|
})
|
||||||
|
|
||||||
|
var swapSpec = api.getSwapSpecification(elt)
|
||||||
|
var target = api.getTarget(elt)
|
||||||
|
api.swap(target, content, swapSpec, { contextElement: elt })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function hasEventSource(node) {
|
||||||
|
return api.getInternalData(node).sseEventSource != null
|
||||||
|
}
|
||||||
|
})()
|
||||||
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,68 @@
|
|||||||
|
{#
|
||||||
|
Shared template macros.
|
||||||
|
|
||||||
|
Import at the top of any template that needs them:
|
||||||
|
{% from "_macros.html" import icon, brand, mark %}
|
||||||
|
#}
|
||||||
|
|
||||||
|
{# An icon from the inlined sprite. `name` omits the "i-" prefix. #}
|
||||||
|
{% macro icon(name, cls="") -%}
|
||||||
|
<svg class="icon {{ cls }}" aria-hidden="true"><use href="#i-{{ name }}"/></svg>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{#
|
||||||
|
The leaf-and-wafer mark, inlined rather than referenced as <img> so it can
|
||||||
|
scale with the surrounding font size. Gradient ids are suffixed with `uid`
|
||||||
|
because ids are document-global: two marks on one page with the same ids
|
||||||
|
means the second silently reuses the first one's gradients.
|
||||||
|
#}
|
||||||
|
{% macro mark(cls="brand-mark", uid="a") -%}
|
||||||
|
<svg class="{{ cls }}" viewBox="0 0 64 64" role="img" aria-label="LLeMbas">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="mk-{{ uid }}-w" x1="0" y1="0" x2="0.3" y2="1">
|
||||||
|
<stop offset="0" stop-color="#EACB74"/>
|
||||||
|
<stop offset="0.5" stop-color="#C9A227"/>
|
||||||
|
<stop offset="1" stop-color="#916F13"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="mk-{{ uid }}-l" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||||
|
<stop offset="0" stop-color="#93A5B6"/>
|
||||||
|
<stop offset="0.4" stop-color="#F1F6FA"/>
|
||||||
|
<stop offset="1" stop-color="#B8C7D5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="mk-{{ uid }}-c"><rect x="6" y="6" width="52" height="52" rx="13"/></clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#mk-{{ uid }}-w)"/>
|
||||||
|
<g clip-path="url(#mk-{{ uid }}-c)" fill="none" stroke-linecap="round">
|
||||||
|
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
|
||||||
|
<path d="M32 6 V58"/><path d="M6 32 H58"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#F6E3A8" 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="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
|
||||||
|
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
|
||||||
|
stroke-linecap="round" fill="none"/>
|
||||||
|
<path d="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"
|
||||||
|
fill="url(#mk-{{ uid }}-l)"/>
|
||||||
|
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A"
|
||||||
|
stroke-opacity="0.5" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{#
|
||||||
|
The wordmark as live text rather than the outlined SVG: it stays selectable,
|
||||||
|
searchable and readable to a screen reader, and scales with the user's font
|
||||||
|
size. The capitals spelling LLM take the gold accent.
|
||||||
|
#}
|
||||||
|
{% macro wordmark() -%}
|
||||||
|
<span class="brand-llm">LL</span>e<span class="brand-llm">M</span>bas
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro brand(href="/", uid="a") -%}
|
||||||
|
<a class="sidebar__brand" href="{{ href }}">
|
||||||
|
{{ mark(uid=uid) }}
|
||||||
|
<span>{{ wordmark() }}</span>
|
||||||
|
</a>
|
||||||
|
{%- endmacro %}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{% from "_macros.html" import icon %}
|
||||||
|
{#
|
||||||
|
One connection: an editable form plus its current status.
|
||||||
|
|
||||||
|
Swapped in place by the "Test & refresh" button, so this fragment has to be
|
||||||
|
able to render on its own as well as inside the list.
|
||||||
|
#}
|
||||||
|
<section class="card connection" id="connection-{{ connection.id }}">
|
||||||
|
<form method="post" action="/admin/connections/{{ connection.id }}" class="form-grid">
|
||||||
|
<div class="connection__head">
|
||||||
|
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
||||||
|
<span class="status-dot {{ 'is-ok' if connection.enabled and not connection.last_error
|
||||||
|
else 'is-bad' if connection.last_error else 'is-off' }}"
|
||||||
|
aria-hidden="true"></span>
|
||||||
|
<strong class="truncate">{{ connection.name }}</strong>
|
||||||
|
{% if model_count is defined %}
|
||||||
|
<span class="badge">{{ model_count }} model{{ '' if model_count == 1 else 's' }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if not connection.enabled %}
|
||||||
|
<span class="badge">disabled</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="gap: var(--sp-2)">
|
||||||
|
<button class="btn btn--sm" type="submit"
|
||||||
|
hx-post="/admin/connections/{{ connection.id }}/test"
|
||||||
|
hx-target="#connection-{{ connection.id }}" hx-swap="outerHTML"
|
||||||
|
formnovalidate>
|
||||||
|
{{ icon("refresh", "icon--sm") }} Test & refresh
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--sm btn--primary" type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert alert--{{ message_kind|default('success') }}">
|
||||||
|
{% if message_kind == "error" %}{{ icon("warning", "alert__icon") }}{% endif %}
|
||||||
|
<span>{{ message }}</span>
|
||||||
|
</div>
|
||||||
|
{% elif connection.last_error %}
|
||||||
|
<div class="alert alert--error">
|
||||||
|
{{ icon("warning", "alert__icon") }}
|
||||||
|
<span>{{ connection.last_error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="name-{{ connection.id }}">Name</label>
|
||||||
|
<input class="input" id="name-{{ connection.id }}" name="name"
|
||||||
|
value="{{ connection.name }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="url-{{ connection.id }}">Base URL</label>
|
||||||
|
<input class="input input--mono" id="url-{{ connection.id }}" name="base_url"
|
||||||
|
value="{{ connection.base_url }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="key-{{ connection.id }}">API key</label>
|
||||||
|
<input class="input input--mono" id="key-{{ connection.id }}" name="api_key"
|
||||||
|
type="password" autocomplete="off"
|
||||||
|
value="{{ unchanged if connection.api_key_encrypted else '' }}"
|
||||||
|
placeholder="No key set">
|
||||||
|
<p class="field__hint">
|
||||||
|
{% if connection.api_key_encrypted %}
|
||||||
|
Currently <code>{{ masked }}</code>. Leave the dots alone to keep it,
|
||||||
|
or clear the field to remove the key entirely.
|
||||||
|
{% else %}
|
||||||
|
No key is stored for this endpoint.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="enabled" value="true"
|
||||||
|
{{ 'checked' if connection.enabled }}>
|
||||||
|
<span>Enabled — its models are offered in chats</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connection__footer">
|
||||||
|
<span class="text-xs faint">
|
||||||
|
{% if connection.last_checked_at %}
|
||||||
|
Last checked {{ connection.last_checked_at.strftime("%Y-%m-%d %H:%M") }} UTC
|
||||||
|
{% else %}
|
||||||
|
Never checked
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<button class="btn btn--sm btn--danger" type="submit"
|
||||||
|
formaction="/admin/connections/{{ connection.id }}/delete" formnovalidate
|
||||||
|
onclick="return confirm('Delete the connection “{{ connection.name }}”? Existing chats keep their history.')">
|
||||||
|
{{ icon("trash", "icon--sm") }} Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import icon, brand %}
|
||||||
|
{#
|
||||||
|
Shared chrome for the admin area: its own narrow nav rather than the chat
|
||||||
|
sidebar, so administration is visibly a different place from chatting.
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar__header">
|
||||||
|
{{ brand(uid="admin") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar__scroll" aria-label="Administration">
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group__label">Administration</div>
|
||||||
|
<a class="nav-item {{ 'is-active' if section == 'connections' }}"
|
||||||
|
href="/admin/connections">
|
||||||
|
{{ icon("server", "icon--sm") }}
|
||||||
|
<span class="nav-item__label">Connections</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item {{ 'is-active' if section == 'models' }}" href="/admin/models">
|
||||||
|
{{ icon("sliders", "icon--sm") }}
|
||||||
|
<span class="nav-item__label">Models</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group__label">Not yet built</div>
|
||||||
|
<span class="nav-item is-disabled">
|
||||||
|
{{ icon("users", "icon--sm") }}
|
||||||
|
<span class="nav-item__label">Users & groups</span>
|
||||||
|
</span>
|
||||||
|
<span class="nav-item is-disabled">
|
||||||
|
{{ icon("gear", "icon--sm") }}
|
||||||
|
<span class="nav-item__label">Tools</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar__footer">
|
||||||
|
<a class="nav-item" href="/chat">
|
||||||
|
{{ icon("chat", "icon--sm") }}
|
||||||
|
<span class="nav-item__label">Back to chats</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1 class="topbar__title">{% block heading %}Administration{% endblock %}</h1>
|
||||||
|
<button class="btn btn--icon" type="button" data-theme-toggle aria-label="Switch theme">
|
||||||
|
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
||||||
|
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="admin-scroll">
|
||||||
|
<div class="admin-page">
|
||||||
|
{% block admin_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "admin/_layout.html" %}
|
||||||
|
{% from "_macros.html" import icon %}
|
||||||
|
{% set section = "connections" %}
|
||||||
|
|
||||||
|
{% block title %}Connections - LLeMbas{% endblock %}
|
||||||
|
{% block heading %}Connections{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<p class="admin-lede">
|
||||||
|
Any endpoint that speaks the OpenAI HTTP API: OpenAI itself, or a local
|
||||||
|
runner such as LM Studio, vLLM, llama.cpp or Ollama. LLeMbas asks each one
|
||||||
|
for its model list and offers those models in the chat picker.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert alert--success">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card__title">Add a connection</h2>
|
||||||
|
<form method="post" action="/admin/connections" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="new-name">Name</label>
|
||||||
|
<input class="input" id="new-name" name="name" required placeholder="Local LM Studio">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="new-url">Base URL</label>
|
||||||
|
<input class="input input--mono" id="new-url" name="base_url" required
|
||||||
|
placeholder="http://localhost:1234/v1">
|
||||||
|
<p class="field__hint">
|
||||||
|
With or without the trailing <code>/v1</code> — both are accepted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="new-key">API key</label>
|
||||||
|
<input class="input input--mono" id="new-key" name="api_key" type="password"
|
||||||
|
autocomplete="off" placeholder="sk-…">
|
||||||
|
<p class="field__hint">
|
||||||
|
Encrypted before it is stored, and never sent back to the browser.
|
||||||
|
Leave empty for endpoints that need no key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--actions">
|
||||||
|
<button class="btn btn--primary" type="submit">
|
||||||
|
{{ icon("plus", "icon--sm") }} Add and load models
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 class="admin-section-title">
|
||||||
|
Configured connections
|
||||||
|
<span class="badge">{{ connections|length }}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{% if not connections %}
|
||||||
|
<div class="empty" style="padding: var(--sp-10) 0">
|
||||||
|
{{ icon("server", "empty__mark") }}
|
||||||
|
<p class="empty__text">
|
||||||
|
Nothing configured yet. Add a connection above and its models appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="connection-list">
|
||||||
|
{% for connection in connections %}
|
||||||
|
{% with masked = masked[connection.id],
|
||||||
|
model_count = model_counts[connection.id] %}
|
||||||
|
{% include "admin/_connection_row.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "admin/_layout.html" %}
|
||||||
|
{% from "_macros.html" import icon %}
|
||||||
|
{% set section = "models" %}
|
||||||
|
|
||||||
|
{% block title %}Models - LLeMbas{% endblock %}
|
||||||
|
{% block heading %}Models{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<p class="admin-lede">
|
||||||
|
Every model discovered on each connection. Disable the ones you do not want
|
||||||
|
cluttering the chat picker — nothing is deleted, and re-running
|
||||||
|
“Test & refresh” will not bring a disabled model back on.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% for connection in connections %}
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card__title">
|
||||||
|
{{ connection.name }}
|
||||||
|
<span class="badge">{{ connection.models|length }}</span>
|
||||||
|
{% if not connection.enabled %}<span class="badge">connection disabled</span>{% endif %}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{% if not connection.models %}
|
||||||
|
<p class="muted text-sm">
|
||||||
|
No models loaded. Run “Test & refresh” on
|
||||||
|
<a href="/admin/connections">the connections page</a>.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="model-list">
|
||||||
|
{% for model in connection.models %}
|
||||||
|
<li class="model-list__item">
|
||||||
|
<code class="model-list__id">{{ model.model_id }}</code>
|
||||||
|
<form method="post" action="/admin/models/{{ model.id }}/toggle">
|
||||||
|
<button class="btn btn--sm {{ 'btn--primary' if model.enabled }}" type="submit">
|
||||||
|
{% if model.enabled %}{{ icon("check", "icon--sm") }} Enabled{% else %}Disabled{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty" style="padding: var(--sp-10) 0">
|
||||||
|
{{ icon("server", "empty__mark") }}
|
||||||
|
<p class="empty__text">
|
||||||
|
No connections yet. <a href="/admin/connections">Add one</a> to load models.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import icon, mark, wordmark %}
|
||||||
|
|
||||||
|
{% block title %}Sign in - LLeMbas{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<main class="auth">
|
||||||
|
<div class="auth__card">
|
||||||
|
<div class="auth__brand">
|
||||||
|
{{ mark(cls="brand-mark", uid="auth") }}
|
||||||
|
<h1 class="auth__title">{{ wordmark() }}</h1>
|
||||||
|
<p class="auth__subtitle">Waybread for the long road of thought.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert--error" role="alert">
|
||||||
|
{{ icon("warning", "alert__icon") }}
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/auth/login" class="stack">
|
||||||
|
<input type="hidden" name="next" value="{{ next|default('/') }}">
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="email">Email</label>
|
||||||
|
<input class="input" type="email" id="email" name="email" required
|
||||||
|
autocomplete="username" autofocus value="{{ email|default('') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="password">Password</label>
|
||||||
|
<input class="input" type="password" id="password" name="password" required
|
||||||
|
autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn--primary btn--block" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if allow_signup %}
|
||||||
|
<p class="auth__footer">
|
||||||
|
No account yet? <a href="/auth/register">Create one</a>.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import icon, mark, wordmark %}
|
||||||
|
|
||||||
|
{% block title %}{% if first_run %}Set up LLeMbas{% else %}Create an account{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<main class="auth">
|
||||||
|
<div class="auth__card">
|
||||||
|
<div class="auth__brand">
|
||||||
|
{{ mark(cls="brand-mark", uid="auth") }}
|
||||||
|
<h1 class="auth__title">{{ wordmark() }}</h1>
|
||||||
|
{% if first_run %}
|
||||||
|
<p class="auth__subtitle">
|
||||||
|
Nobody has claimed this instance yet. The first account becomes its administrator.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="auth__subtitle">Create your account.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert--error" role="alert">
|
||||||
|
{{ icon("warning", "alert__icon") }}
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/auth/register" class="stack">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="name">Name</label>
|
||||||
|
<input class="input" type="text" id="name" name="name" required autofocus
|
||||||
|
autocomplete="name" value="{{ name|default('') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="email">Email</label>
|
||||||
|
<input class="input" type="email" id="email" name="email" required
|
||||||
|
autocomplete="username" value="{{ email|default('') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="password">Password</label>
|
||||||
|
<input class="input" type="password" id="password" name="password" required
|
||||||
|
autocomplete="new-password" minlength="8">
|
||||||
|
<p class="field__hint">At least 8 characters.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn--primary btn--block" type="submit">
|
||||||
|
{% if first_run %}Create administrator account{% else %}Create account{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if not first_run %}
|
||||||
|
<p class="auth__footer">
|
||||||
|
Already have an account? <a href="/auth/login">Sign in</a>.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="{{ theme }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}LLeMbas{% endblock %}</title>
|
||||||
|
<meta name="description" content="LLeMbas - a web UI for your language models.">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
|
||||||
|
<link rel="icon" href="{{ url_for('static', path='img/favicon.svg') }}" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='css/tokens.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}">
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
|
||||||
|
{#
|
||||||
|
Applied before first paint so a reload never flashes the wrong theme. The
|
||||||
|
server already rendered data-theme from the signed-in user's preference; this
|
||||||
|
only corrects it for a visitor whose local choice differs (or who is signed
|
||||||
|
out, where the server has nothing to go on).
|
||||||
|
#}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem("lembas-theme");
|
||||||
|
if (stored && stored !== document.documentElement.dataset.theme) {
|
||||||
|
document.documentElement.dataset.theme = stored;
|
||||||
|
}
|
||||||
|
} catch (e) { /* private mode: the server-rendered theme stands */ }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body{% block body_attrs %}{% endblock %}>
|
||||||
|
{% include "partials/icons.html" %}
|
||||||
|
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', path='vendor/htmx.min.js') }}" defer></script>
|
||||||
|
<script src="{{ url_for('static', path='vendor/htmx-ext-sse.js') }}" defer></script>
|
||||||
|
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script>
|
||||||
|
<script src="{{ url_for('static', path='js/app.js') }}" defer></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</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) %}
|
||||||
|
|
||||||
|
<article class="msg msg--{{ message.role }}" id="msg-{{ message.id }}"
|
||||||
|
{% if streaming %}
|
||||||
|
hx-ext="sse"
|
||||||
|
sse-connect="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stream"
|
||||||
|
sse-close="close"
|
||||||
|
{% endif %}>
|
||||||
|
|
||||||
|
<div class="msg__gutter" aria-hidden="true">
|
||||||
|
{% if message.role == "assistant" %}
|
||||||
|
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
|
||||||
|
{% else %}
|
||||||
|
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="msg__main">
|
||||||
|
<header class="msg__meta">
|
||||||
|
<span class="msg__author">
|
||||||
|
{{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }}
|
||||||
|
</span>
|
||||||
|
{% if message.model_id %}
|
||||||
|
<span class="msg__model" title="{{ message.model_id }}">{{ message.model_id }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% 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. #}
|
||||||
|
<div class="msg__body msg__body--streaming" id="stream-{{ message.id }}"
|
||||||
|
sse-swap="token" hx-swap="beforeend"></div>
|
||||||
|
<div class="msg__waiting">
|
||||||
|
<span class="dots"><i></i><i></i><i></i></span>
|
||||||
|
</div>
|
||||||
|
{% elif message.error %}
|
||||||
|
<div class="alert alert--error msg__error" role="alert">
|
||||||
|
{{ icon("warning", "alert__icon") }}
|
||||||
|
<div>
|
||||||
|
<strong>The reply could not be completed.</strong>
|
||||||
|
<div class="text-sm" style="margin-top: var(--sp-1)">{{ message.error }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if message.content %}
|
||||||
|
<div class="msg__body">{{ body_html|safe }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif message.role == "assistant" %}
|
||||||
|
<div class="msg__body">{{ body_html|safe }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="msg__body msg__body--plain">{{ message.content }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not streaming %}
|
||||||
|
<footer class="msg__actions">
|
||||||
|
<button class="btn btn--icon btn--sm" type="button"
|
||||||
|
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
|
||||||
|
{{ icon("copy", "icon--sm") }}
|
||||||
|
</button>
|
||||||
|
{% if message.role == "assistant" %}
|
||||||
|
<button class="btn btn--icon btn--sm" type="button"
|
||||||
|
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/regenerate"
|
||||||
|
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
|
||||||
|
aria-label="Regenerate reply">
|
||||||
|
{{ icon("refresh", "icon--sm") }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</footer>
|
||||||
|
{# The raw source, so the copy button yields Markdown rather than rendered
|
||||||
|
text. A hidden div and not a <script>: script content is raw text, so
|
||||||
|
the escaping Jinja applies would be copied out literally as entities. #}
|
||||||
|
<div hidden id="msg-body-{{ message.id }}">{{ message.content }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if streaming %}
|
||||||
|
{# Receives the finished bubble and replaces this whole article with it. #}
|
||||||
|
<div hidden sse-swap="done" hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"></div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{#
|
||||||
|
Out-of-band updates sent alongside the finished reply.
|
||||||
|
|
||||||
|
A chat is named from its first exchange, which happens on the server while
|
||||||
|
the reply streams. These two fragments push the new title into the page
|
||||||
|
without the client having to poll or reload.
|
||||||
|
#}
|
||||||
|
<span id="chat-title" hx-swap-oob="true">{{ chat.title }}</span>
|
||||||
|
<span id="chat-link-label-{{ chat.id }}" hx-swap-oob="true"
|
||||||
|
class="nav-item__label">{{ chat.title }}</span>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{#
|
||||||
|
One exchange: the user's message plus the empty assistant bubble that will
|
||||||
|
stream into it. Returned by POST /api/chats/{id}/messages and appended to the
|
||||||
|
thread in a single swap, so the pair always arrives together.
|
||||||
|
#}
|
||||||
|
{% with message = user_message %}
|
||||||
|
{% include "chat/_message.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% with message = assistant_message %}
|
||||||
|
{% include "chat/_message.html" %}
|
||||||
|
{% endwith %}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import icon, mark %}
|
||||||
|
|
||||||
|
{% block title %}{{ chat.title if chat else "Chats" }} - LLeMbas{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="shell">
|
||||||
|
{% include "partials/sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="btn btn--icon" type="button" aria-label="Toggle sidebar"
|
||||||
|
onclick="document.getElementById('sidebar').toggleAttribute('hidden')">
|
||||||
|
{{ icon("sidebar") }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% if chat %}
|
||||||
|
<h1 class="topbar__title"><span id="chat-title">{{ chat.title }}</span></h1>
|
||||||
|
|
||||||
|
{% if models %}
|
||||||
|
<form hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change from:find select">
|
||||||
|
<select class="select select--compact" name="model_id" aria-label="Model">
|
||||||
|
{% for model in models %}
|
||||||
|
<option value="{{ model.model_id }}" {{ 'selected' if model.model_id == chat.model_id }}>
|
||||||
|
{{ model.label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<h1 class="topbar__title">Chats</h1>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if not chat %}
|
||||||
|
{# No chat selected. #}
|
||||||
|
<div class="empty">
|
||||||
|
{{ mark(cls="empty__mark", uid="empty") }}
|
||||||
|
<h2 class="empty__title">The road goes ever on</h2>
|
||||||
|
<p class="empty__text">
|
||||||
|
Pick a chat from the side, or start a new one.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn--primary" hx-post="/api/chats" hx-swap="none">
|
||||||
|
{{ icon("plus", "icon--sm") }} New chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif not models %}
|
||||||
|
{# Nothing to talk to yet. This is the state every fresh install lands in,
|
||||||
|
so it points straight at the fix rather than just reporting a problem. #}
|
||||||
|
<div class="empty">
|
||||||
|
{{ icon("server", "empty__mark") }}
|
||||||
|
<h2 class="empty__title">No models available</h2>
|
||||||
|
<p class="empty__text">
|
||||||
|
{% if user.is_admin %}
|
||||||
|
Add an OpenAI-compatible connection and LLeMbas will load its models.
|
||||||
|
{% else %}
|
||||||
|
No model connections have been set up yet. Ask an administrator.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if user.is_admin %}
|
||||||
|
<a class="btn btn--primary" href="/admin/connections">
|
||||||
|
{{ icon("server", "icon--sm") }} Set up a connection
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="thread-scroll" id="thread-scroll">
|
||||||
|
<div class="thread" id="thread">
|
||||||
|
{% if not messages %}
|
||||||
|
<div class="thread__intro">
|
||||||
|
{{ mark(cls="empty__mark", uid="intro") }}
|
||||||
|
<h2 class="empty__title">What would you ask?</h2>
|
||||||
|
<p class="empty__text">Speak, friend, and enter.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for message in messages %}
|
||||||
|
{# Markdown was rendered server-side in pages.py, keyed by message
|
||||||
|
id, so this loop stays a lookup rather than a render. #}
|
||||||
|
{% with body_html = bodies.get(message.id, "") %}
|
||||||
|
{% include "chat/_message.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
<form class="composer__form"
|
||||||
|
hx-post="/api/chats/{{ chat.id }}/messages"
|
||||||
|
hx-target="#thread" hx-swap="beforeend"
|
||||||
|
hx-on::after-request="if (event.detail.successful) {
|
||||||
|
this.reset();
|
||||||
|
const t = this.querySelector('textarea');
|
||||||
|
window.lembas.autosize(t);
|
||||||
|
window.lembas.scrollThread(true);
|
||||||
|
}">
|
||||||
|
<textarea class="composer__input" name="content" rows="1"
|
||||||
|
data-autosize data-max-height="320" data-composer-input
|
||||||
|
placeholder="Send a message…" aria-label="Message"></textarea>
|
||||||
|
<button class="btn btn--primary composer__send" type="submit" aria-label="Send">
|
||||||
|
{{ icon("send", "icon--sm") }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="composer__hint">
|
||||||
|
Enter to send, Shift+Enter for a new line.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import mark %}
|
||||||
|
|
||||||
|
{% block title %}{{ status_code }} - LLeMbas{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<main class="auth">
|
||||||
|
<div class="auth__card" style="text-align: center">
|
||||||
|
{{ mark(cls="empty__mark", uid="err") }}
|
||||||
|
<h1 class="auth__title" style="margin-top: var(--sp-4)">{{ status_code }}</h1>
|
||||||
|
<p class="empty__text" style="margin: var(--sp-3) auto var(--sp-5)">{{ flavour }}</p>
|
||||||
|
{% if detail and detail != flavour %}
|
||||||
|
<p class="text-sm muted" style="margin-bottom: var(--sp-5)">{{ detail }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn btn--primary" href="/">Back to your chats</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% from "_macros.html" import icon %}
|
||||||
|
{#
|
||||||
|
A single chat row. `chat_item` rather than `chat` so this can be included
|
||||||
|
from a page that already has the open chat bound to `chat`.
|
||||||
|
#}
|
||||||
|
<div class="nav-item {% if chat and chat.id == chat_item.id %}is-active{% endif %}"
|
||||||
|
data-chat-id="{{ chat_item.id }}">
|
||||||
|
<a class="nav-item__link" href="/chat/{{ chat_item.id }}">
|
||||||
|
{{ icon("chat", "icon--sm") }}
|
||||||
|
<span class="nav-item__label" id="chat-link-label-{{ chat_item.id }}">{{ chat_item.title }}</span>
|
||||||
|
</a>
|
||||||
|
<span class="nav-item__actions">
|
||||||
|
<button class="btn btn--icon btn--sm" type="button"
|
||||||
|
hx-delete="/api/chats/{{ chat_item.id }}"
|
||||||
|
hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone."
|
||||||
|
hx-swap="none"
|
||||||
|
aria-label="Delete chat">
|
||||||
|
{{ icon("trash", "icon--sm") }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% from "_macros.html" import icon %}
|
||||||
|
{#
|
||||||
|
A folder and everything under it. Recursive: a folder includes this same
|
||||||
|
template for each of its children, so nesting has no fixed depth limit.
|
||||||
|
|
||||||
|
Open/closed state is local to the browser (Alpine), not persisted per
|
||||||
|
request -- collapsing a folder should not cost a round trip.
|
||||||
|
#}
|
||||||
|
<div class="folder" x-data="{ open: {{ 'false' if folder.collapsed else 'true' }} }">
|
||||||
|
<div class="nav-item folder__row">
|
||||||
|
<button class="folder__toggle" type="button" @click="open = !open"
|
||||||
|
:aria-expanded="open ? 'true' : 'false'">
|
||||||
|
<span class="folder__chevron" :class="open && 'is-open'">
|
||||||
|
{{ icon("chevron-right", "icon--sm") }}
|
||||||
|
</span>
|
||||||
|
<span x-show="open">{{ icon("folder-open", "icon--sm") }}</span>
|
||||||
|
<span x-show="!open" x-cloak>{{ icon("folder", "icon--sm") }}</span>
|
||||||
|
<span class="nav-item__label">{{ folder.name }}</span>
|
||||||
|
</button>
|
||||||
|
<span class="nav-item__actions">
|
||||||
|
<button class="btn btn--icon btn--sm" type="button"
|
||||||
|
hx-delete="/api/folders/{{ folder.id }}"
|
||||||
|
hx-confirm="Delete the folder “{{ folder.name }}”? Chats inside it are kept."
|
||||||
|
hx-swap="none"
|
||||||
|
aria-label="Delete folder">
|
||||||
|
{{ icon("trash", "icon--sm") }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="folder__contents" x-show="open" x-cloak>
|
||||||
|
{% for child in folder.children %}
|
||||||
|
{% with folder = child %}
|
||||||
|
{% include "partials/_folder.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for chat_item in folder.chats %}
|
||||||
|
{% include "partials/_chat_link.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if not folder.children and not folder.chats %}
|
||||||
|
<p class="nav-empty">Empty</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{% from "_macros.html" import icon, brand %}
|
||||||
|
{#
|
||||||
|
Sidebar: brand, new chat, the folder tree, then unfiled chats.
|
||||||
|
|
||||||
|
Folders render recursively through _folder.html. Chats appear under their
|
||||||
|
folder, and any chat without one falls to the flat list at the bottom.
|
||||||
|
#}
|
||||||
|
<aside class="sidebar" id="sidebar" x-data="{ }">
|
||||||
|
<div class="sidebar__header">
|
||||||
|
{{ brand(uid="side") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar__actions">
|
||||||
|
<button class="btn btn--primary btn--block" hx-post="/api/chats" hx-swap="none">
|
||||||
|
{{ icon("plus", "icon--sm") }} New chat
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--icon" hx-post="/api/folders" hx-swap="none"
|
||||||
|
hx-vals='{"name": "New folder"}' aria-label="New folder" title="New folder">
|
||||||
|
{{ icon("folder") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar__scroll" id="sidebar-tree" aria-label="Chats">
|
||||||
|
{% if folders %}
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group__label">Folders</div>
|
||||||
|
{% for folder in folders %}
|
||||||
|
{% include "partials/_folder.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group__label">Chats</div>
|
||||||
|
{% if unfiled_chats %}
|
||||||
|
{% for chat_item in unfiled_chats %}
|
||||||
|
{% include "partials/_chat_link.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="nav-empty">No chats yet. The road begins here.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar__footer">
|
||||||
|
<div class="row row--between">
|
||||||
|
<a class="nav-item" href="/settings" style="flex: 1">
|
||||||
|
{{ icon("user", "icon--sm") }}
|
||||||
|
<span class="nav-item__label">{{ user.name }}</span>
|
||||||
|
{% if user.is_admin %}<span class="badge badge--gold">admin</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="gap: var(--sp-1); margin-top: var(--sp-1)">
|
||||||
|
{% if user.is_admin %}
|
||||||
|
<a class="btn btn--icon" href="/admin" aria-label="Admin settings" title="Admin settings">
|
||||||
|
{{ icon("shield") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn--icon" type="button" data-theme-toggle
|
||||||
|
aria-label="Switch theme" title="Switch theme">
|
||||||
|
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
||||||
|
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
|
||||||
|
</button>
|
||||||
|
<span class="topbar__spacer"></span>
|
||||||
|
<form method="post" action="/auth/logout">
|
||||||
|
<button class="btn btn--icon" type="submit" aria-label="Sign out" title="Sign out">
|
||||||
|
{{ icon("logout") }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import icon %}
|
||||||
|
|
||||||
|
{% block title %}Your settings - LLeMbas{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="shell">
|
||||||
|
{% include "partials/sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1 class="topbar__title">Your settings</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="admin-scroll">
|
||||||
|
<div class="admin-page">
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card__title">Account</h2>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field__label">Name</span>
|
||||||
|
<p>{{ user.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field__label">Email</span>
|
||||||
|
<p class="mono text-sm">{{ user.email }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field__label">Role</span>
|
||||||
|
<p>
|
||||||
|
<span class="badge {{ 'badge--gold' if user.is_admin }}">{{ user.role }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card__title">Appearance</h2>
|
||||||
|
<div class="row" style="gap: var(--sp-3)">
|
||||||
|
<button class="btn" type="button" onclick="window.lembas.applyTheme('moria')">
|
||||||
|
{{ icon("moon", "icon--sm") }} Moria
|
||||||
|
</button>
|
||||||
|
<button class="btn" type="button" onclick="window.lembas.applyTheme('shire')">
|
||||||
|
{{ icon("sun", "icon--sm") }} Shire
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="field__hint" style="margin-top: var(--sp-3)">
|
||||||
|
Moria is the dark theme, Shire the light one. Your choice is saved
|
||||||
|
to this browser and to your account.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card__title">Session</h2>
|
||||||
|
<form method="post" action="/auth/logout">
|
||||||
|
<button class="btn btn--danger" type="submit">
|
||||||
|
{{ icon("logout", "icon--sm") }} Sign out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
if user is not None:
|
||||||
|
chosen = (user.settings_json or {}).get("theme")
|
||||||
|
if chosen in ("moria", "shire"):
|
||||||
|
return chosen
|
||||||
|
return settings.default_theme
|
||||||
|
|
||||||
|
|
||||||
|
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),
|
||||||
|
"version": __version__,
|
||||||
|
"allow_signup": settings.allow_signup,
|
||||||
|
}
|
||||||
|
payload.update(context or {})
|
||||||
|
return templates.TemplateResponse(request, template, payload, **kwargs)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Test fixtures.
|
||||||
|
|
||||||
|
Every test runs against a throwaway SQLite file in a tmp_path, never the real
|
||||||
|
data directory. The environment has to be set before lembas.config is imported,
|
||||||
|
because Settings is a cached singleton read at import time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Must happen before any lembas import.
|
||||||
|
_TMP = tempfile.mkdtemp(prefix="lembas-tests-")
|
||||||
|
os.environ.update(
|
||||||
|
{
|
||||||
|
"LEMBAS_SECRET_KEY": "test-secret-key-not-for-real-use",
|
||||||
|
"LEMBAS_DATA_DIR": _TMP,
|
||||||
|
"LEMBAS_ALLOW_SIGNUP": "true",
|
||||||
|
"LEMBAS_LOG_LEVEL": "warning",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
from sqlalchemy.orm import Session # noqa: E402
|
||||||
|
|
||||||
|
from lembas.config import settings # noqa: E402
|
||||||
|
from lembas.db.base import Base # noqa: E402
|
||||||
|
from lembas.db.session import get_engine, get_session_factory, reset_engine # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_database(tmp_path: Path) -> Iterator[None]:
|
||||||
|
"""Point the engine at a per-test database and build the schema.
|
||||||
|
|
||||||
|
reset_engine() is essential: the engine is a module-level singleton, so
|
||||||
|
without it every test after the first would share the first one's file.
|
||||||
|
"""
|
||||||
|
settings.data_dir = tmp_path
|
||||||
|
reset_engine()
|
||||||
|
settings.ensure_dirs()
|
||||||
|
|
||||||
|
import lembas.db.models # noqa: F401 (registers the tables)
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=get_engine())
|
||||||
|
yield
|
||||||
|
reset_engine()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db() -> Iterator[Session]:
|
||||||
|
session = get_session_factory()()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> Iterator[TestClient]:
|
||||||
|
from lembas.main import app
|
||||||
|
|
||||||
|
# raise_server_exceptions=False so error-handler behaviour is exercised
|
||||||
|
# rather than the exception propagating into the test.
|
||||||
|
with TestClient(app, raise_server_exceptions=False) as test_client:
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def registered(client: TestClient) -> dict[str, str]:
|
||||||
|
"""Register the first account. It becomes the administrator."""
|
||||||
|
credentials = {
|
||||||
|
"name": "Frodo",
|
||||||
|
"email": "frodo@shire.test",
|
||||||
|
"password": "speak-friend-and-enter",
|
||||||
|
}
|
||||||
|
response = client.post("/auth/register", data=credentials, follow_redirects=False)
|
||||||
|
assert response.status_code == 303, response.text
|
||||||
|
return credentials
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_id(db: Session, registered: dict[str, str]) -> str:
|
||||||
|
"""The registered user's id.
|
||||||
|
|
||||||
|
Chats have a real foreign key to users and SQLite enforces it (the
|
||||||
|
connect-time PRAGMA in db/session.py turns that on), so tests that build a
|
||||||
|
Chat directly need a user that actually exists.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from lembas.db.models import User
|
||||||
|
|
||||||
|
return db.scalar(select(User).where(User.email == registered["email"])).id
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Registration, sign-in, and the route guards."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from lembas.db.models import ROLE_ADMIN, ROLE_USER, User
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_instance_sends_you_to_registration(client: TestClient):
|
||||||
|
response = client.get("/auth/login", follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/auth/register"
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_account_becomes_admin(client: TestClient, db, registered):
|
||||||
|
user = db.scalar(select(User).where(User.email == registered["email"]))
|
||||||
|
assert user.role == ROLE_ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_account_is_an_ordinary_user(client: TestClient, db, registered):
|
||||||
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
|
client.post(
|
||||||
|
"/auth/register",
|
||||||
|
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
user = db.scalar(select(User).where(User.email == "sam@shire.test"))
|
||||||
|
assert user.role == ROLE_USER
|
||||||
|
|
||||||
|
|
||||||
|
def test_registration_signs_you_in(client: TestClient, registered):
|
||||||
|
assert client.get("/chat").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_email_is_rejected(client: TestClient, registered):
|
||||||
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
|
response = client.post("/auth/register", data=registered, follow_redirects=False)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "already exists" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_password_is_rejected(client: TestClient):
|
||||||
|
response = client.post(
|
||||||
|
"/auth/register",
|
||||||
|
data={"name": "A", "email": "a@b.test", "password": "short"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "8 characters" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_in_and_out(client: TestClient, registered):
|
||||||
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
|
assert client.get("/chat", follow_redirects=False).status_code == 303
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"email": registered["email"], "password": registered["password"]},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert client.get("/chat").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_password_does_not_reveal_whether_the_account_exists(
|
||||||
|
client: TestClient, registered
|
||||||
|
):
|
||||||
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
|
wrong = client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"email": registered["email"], "password": "wrong-password-here"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
missing = client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"email": "nobody@nowhere.test", "password": "wrong-password-here"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert wrong.status_code == missing.status_code == 401
|
||||||
|
assert "do not match" in wrong.text
|
||||||
|
# Identical wording is the whole point: the form must not be usable to
|
||||||
|
# enumerate which addresses are registered.
|
||||||
|
assert ("do not match" in missing.text) == ("do not match" in wrong.text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_revokes_the_session_immediately(client: TestClient, registered):
|
||||||
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
|
assert client.get("/chat", follow_redirects=False).status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_out_pages_redirect_to_login(client: TestClient, registered):
|
||||||
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
|
for path in ("/", "/chat", "/settings", "/admin/connections"):
|
||||||
|
assert client.get(path, follow_redirects=False).status_code == 303, path
|
||||||
|
|
||||||
|
|
||||||
|
def test_htmx_requests_get_a_redirect_header_not_a_login_page(
|
||||||
|
client: TestClient, registered
|
||||||
|
):
|
||||||
|
"""An htmx request must never swap a login form into a fragment of the UI."""
|
||||||
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
|
response = client.post("/api/chats", headers={"HX-Request": "true"})
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert response.headers["HX-Redirect"] == "/auth/login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_area_is_closed_to_ordinary_users(client: TestClient, registered):
|
||||||
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
|
client.post(
|
||||||
|
"/auth/register",
|
||||||
|
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert client.get("/admin/connections", follow_redirects=False).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_next_parameter_cannot_be_used_for_an_open_redirect(
|
||||||
|
client: TestClient, registered
|
||||||
|
):
|
||||||
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
|
response = client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={
|
||||||
|
"email": registered["email"],
|
||||||
|
"password": registered["password"],
|
||||||
|
"next": "https://evil.example.com/steal",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""Chat, folders, and the streaming reply path against a mocked endpoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from lembas.db.models import Chat, Connection, Folder, Message, Model
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
from lembas.services.crypto import encrypt
|
||||||
|
from lembas.services.llm.openai_client import Endpoint, LLMError, delta_text, list_models
|
||||||
|
from lembas.services.sse import event
|
||||||
|
|
||||||
|
|
||||||
|
# --- SSE framing -------------------------------------------------------------
|
||||||
|
def test_sse_event_framing():
|
||||||
|
assert event("token", "hello") == "event: token\ndata: hello\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sse_splits_newlines_across_data_lines():
|
||||||
|
"""A payload with a newline must become several data: lines. Sending a raw
|
||||||
|
newline truncates the event, which is what breaks the first code block a
|
||||||
|
model emits."""
|
||||||
|
assert event("token", "a\nb") == "event: token\ndata: a\ndata: b\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sse_round_trips_through_the_browser_rejoin_rule():
|
||||||
|
payload = "line one\nline two\n\nline four"
|
||||||
|
framed = event("token", payload)
|
||||||
|
body = framed.split("\n", 1)[1]
|
||||||
|
rejoined = "\n".join(
|
||||||
|
line.removeprefix("data: ") for line in body.split("\n") if line.startswith("data:")
|
||||||
|
)
|
||||||
|
assert rejoined == payload
|
||||||
|
|
||||||
|
|
||||||
|
# --- Delta parsing -----------------------------------------------------------
|
||||||
|
def test_delta_text_reads_the_normal_shape():
|
||||||
|
assert delta_text({"choices": [{"delta": {"content": "hi"}}]}) == "hi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delta_text_handles_typed_content_parts():
|
||||||
|
chunk = {"choices": [{"delta": {"content": [{"type": "text", "text": "hi"}]}}]}
|
||||||
|
assert delta_text(chunk) == "hi"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"chunk", [{}, {"choices": []}, {"choices": [{}]}, {"choices": [{"delta": {}}]}]
|
||||||
|
)
|
||||||
|
def test_delta_text_tolerates_junk(chunk):
|
||||||
|
"""Providers vary; an unexpected chunk shape must not kill a reply."""
|
||||||
|
assert delta_text(chunk) == ""
|
||||||
|
|
||||||
|
|
||||||
|
# --- Endpoint URL handling ---------------------------------------------------
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("base", "expected"),
|
||||||
|
[
|
||||||
|
("http://host:1234", "http://host:1234/v1/models"),
|
||||||
|
("http://host:1234/v1", "http://host:1234/v1/models"),
|
||||||
|
("http://host:1234/", "http://host:1234/v1/models"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_base_url_with_or_without_v1(base, expected):
|
||||||
|
"""Users should not have to guess which form is expected."""
|
||||||
|
assert Endpoint(base_url=base.rstrip("/"), api_key="", extra_headers={}).url(
|
||||||
|
"models"
|
||||||
|
) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_authorization_header_without_a_key():
|
||||||
|
"""Local runners often reject an empty bearer token outright."""
|
||||||
|
assert "Authorization" not in Endpoint("http://h", "", {}).headers()
|
||||||
|
assert Endpoint("http://h", "k", {}).headers()["Authorization"] == "Bearer k"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Model discovery ---------------------------------------------------------
|
||||||
|
async def test_list_models_accepts_the_bare_list_shape():
|
||||||
|
"""The spec says {"data": [...]}, but some servers return a bare list."""
|
||||||
|
|
||||||
|
def handler(_request):
|
||||||
|
return httpx.Response(200, json=[{"id": "a"}, {"id": "b"}])
|
||||||
|
|
||||||
|
original = httpx.AsyncClient
|
||||||
|
|
||||||
|
class Patched(original):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(transport=httpx.MockTransport(handler), **kwargs)
|
||||||
|
|
||||||
|
httpx.AsyncClient = Patched
|
||||||
|
try:
|
||||||
|
assert [m["id"] for m in await list_models(Endpoint("http://h", "", {}))] == ["a", "b"]
|
||||||
|
finally:
|
||||||
|
httpx.AsyncClient = original
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_models_reports_a_rejected_key_readably():
|
||||||
|
def handler(_request):
|
||||||
|
return httpx.Response(401, json={"error": {"message": "Incorrect API key."}})
|
||||||
|
|
||||||
|
original = httpx.AsyncClient
|
||||||
|
|
||||||
|
class Patched(original):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(transport=httpx.MockTransport(handler), **kwargs)
|
||||||
|
|
||||||
|
httpx.AsyncClient = Patched
|
||||||
|
try:
|
||||||
|
with pytest.raises(LLMError) as caught:
|
||||||
|
await list_models(Endpoint("http://h", "bad", {}))
|
||||||
|
assert "rejected" in caught.value.message
|
||||||
|
assert "Incorrect API key." in caught.value.message
|
||||||
|
finally:
|
||||||
|
httpx.AsyncClient = original
|
||||||
|
|
||||||
|
|
||||||
|
# --- Titles ------------------------------------------------------------------
|
||||||
|
def test_fallback_title_keeps_a_short_message_intact():
|
||||||
|
assert chat_service.fallback_title("What is lembas?") == "What is lembas?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_title_trims_on_a_word_boundary():
|
||||||
|
title = chat_service.fallback_title("word " * 60)
|
||||||
|
assert len(title) <= chat_service.MAX_TITLE_LENGTH + 1
|
||||||
|
assert title.endswith("…")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_title_of_nothing():
|
||||||
|
assert chat_service.fallback_title(" ") == "New chat"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Chats and folders (through the API) -------------------------------------
|
||||||
|
def _add_connection(db) -> Connection:
|
||||||
|
# Port 1 refuses connections, which is what the error-path test relies on.
|
||||||
|
connection = Connection(
|
||||||
|
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
|
||||||
|
)
|
||||||
|
db.add(connection)
|
||||||
|
db.commit()
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="test-model"))
|
||||||
|
db.commit()
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_chat_redirects_to_its_own_url(client: TestClient, db, registered):
|
||||||
|
_add_connection(db)
|
||||||
|
response = client.post("/api/chats", headers={"HX-Request": "true"})
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert response.headers["HX-Redirect"].startswith("/chat/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_chat_picks_up_the_default_model(client: TestClient, db, registered):
|
||||||
|
_add_connection(db)
|
||||||
|
client.post("/api/chats", headers={"HX-Request": "true"})
|
||||||
|
assert db.scalar(select(Chat)).model_id == "test-model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_posting_a_message_stores_both_turns(client: TestClient, db, registered):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
||||||
|
|
||||||
|
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello there"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
messages = db.scalars(select(Message).order_by(Message.created_at)).all()
|
||||||
|
assert [m.role for m in messages] == ["user", "assistant"]
|
||||||
|
assert messages[0].content == "Hello there"
|
||||||
|
# The assistant row is created empty and incomplete; that is what carries
|
||||||
|
# the sse-connect the browser uses to start the stream.
|
||||||
|
assert messages[1].content == ""
|
||||||
|
assert messages[1].complete is False
|
||||||
|
assert "sse-connect" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_message_is_ignored(client: TestClient, db, registered):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
||||||
|
assert client.post(f"/api/chats/{chat_id}/messages", data={"content": " "}).status_code == 204
|
||||||
|
assert db.scalar(select(Message)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_chat_belonging_to_someone_else_is_not_found(client: TestClient, db, registered):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
||||||
|
|
||||||
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
|
client.post(
|
||||||
|
"/auth/register",
|
||||||
|
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
# 404 and not 403: whether that id exists is not this endpoint's to reveal.
|
||||||
|
assert client.get(f"/chat/{chat_id}").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_renaming_a_chat_stops_it_being_auto_titled(client: TestClient, db, registered):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
||||||
|
client.patch(f"/api/chats/{chat_id}", data={"title": "My own title"})
|
||||||
|
|
||||||
|
chat = db.get(Chat, chat_id)
|
||||||
|
db.refresh(chat)
|
||||||
|
assert chat.title == "My own title"
|
||||||
|
assert chat.title_generated is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_a_chat_removes_its_messages(client: TestClient, db, registered):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
||||||
|
client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello"})
|
||||||
|
|
||||||
|
client.delete(f"/api/chats/{chat_id}")
|
||||||
|
assert db.scalar(select(Chat)) is None
|
||||||
|
assert db.scalar(select(Message)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_a_folder_keeps_the_chats_inside_it(client: TestClient, db, registered):
|
||||||
|
"""Losing a conversation to a mis-clicked folder delete is unforgivable."""
|
||||||
|
_add_connection(db)
|
||||||
|
client.post("/api/folders", data={"name": "Quests"})
|
||||||
|
folder = db.scalar(select(Folder))
|
||||||
|
|
||||||
|
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
||||||
|
client.patch(f"/api/chats/{chat_id}", data={"folder_id": folder.id})
|
||||||
|
|
||||||
|
client.delete(f"/api/folders/{folder.id}")
|
||||||
|
|
||||||
|
chat = db.get(Chat, chat_id)
|
||||||
|
db.refresh(chat)
|
||||||
|
assert chat is not None
|
||||||
|
assert chat.folder_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_folder_cannot_be_moved_inside_itself(client: TestClient, db, registered):
|
||||||
|
client.post("/api/folders", data={"name": "Outer"})
|
||||||
|
folder = db.scalar(select(Folder))
|
||||||
|
response = client.patch(f"/api/folders/{folder.id}", data={"parent_id": folder.id})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# --- Request building --------------------------------------------------------
|
||||||
|
def test_request_forwards_only_known_sampling_parameters(db, user_id):
|
||||||
|
"""A stray key in params_json must not become a 400 from the provider that
|
||||||
|
looks like a LLeMbas bug."""
|
||||||
|
connection = _add_connection(db)
|
||||||
|
chat = Chat(
|
||||||
|
user_id=user_id,
|
||||||
|
model_id="test-model",
|
||||||
|
connection_id=connection.id,
|
||||||
|
params_json={"temperature": 0.4, "nonsense": "drop me"},
|
||||||
|
)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
payload = chat_service.build_request(db, chat)
|
||||||
|
assert payload["temperature"] == 0.4
|
||||||
|
assert "nonsense" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_skips_failed_and_empty_turns(db, user_id):
|
||||||
|
"""Sending an empty assistant message upsets several providers."""
|
||||||
|
connection = _add_connection(db)
|
||||||
|
chat = Chat(user_id=user_id, model_id="test-model", connection_id=connection.id)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Message(chat_id=chat.id, role="user", content="one"),
|
||||||
|
Message(chat_id=chat.id, role="assistant", content="", error="boom"),
|
||||||
|
Message(chat_id=chat.id, role="user", content="two"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
contents = [m["content"] for m in chat_service.build_request(db, chat)["messages"]]
|
||||||
|
assert contents == ["one", "two"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_prompt_leads_the_message_list(db, user_id):
|
||||||
|
connection = _add_connection(db)
|
||||||
|
chat = Chat(
|
||||||
|
user_id=user_id,
|
||||||
|
model_id="test-model",
|
||||||
|
connection_id=connection.id,
|
||||||
|
system_prompt="You are terse.",
|
||||||
|
)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
messages = chat_service.build_request(db, chat)["messages"]
|
||||||
|
assert messages[0] == {"role": "system", "content": "You are terse."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_reports_an_unreachable_endpoint_in_the_thread(
|
||||||
|
client: TestClient, db, registered
|
||||||
|
):
|
||||||
|
"""A failed turn must never be an unexplained blank bubble."""
|
||||||
|
_add_connection(db) # points at 127.0.0.1:1, which refuses connections
|
||||||
|
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
||||||
|
client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello"})
|
||||||
|
message = db.scalar(select(Message).where(Message.role == "assistant"))
|
||||||
|
|
||||||
|
response = client.get(f"/api/chats/{chat_id}/messages/{message.id}/stream")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Could not reach" in response.text
|
||||||
|
assert "alert--error" in response.text
|
||||||
|
|
||||||
|
db.refresh(message)
|
||||||
|
assert message.complete is True
|
||||||
|
assert message.error
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Secret encryption and password hashing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from lembas.security.passwords import (
|
||||||
|
hash_password,
|
||||||
|
validate_password,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from lembas.services.crypto import decrypt, encrypt, mask
|
||||||
|
|
||||||
|
|
||||||
|
def test_encrypt_round_trip():
|
||||||
|
secret = "sk-proj-abcdef1234567890"
|
||||||
|
assert decrypt(encrypt(secret)) == secret
|
||||||
|
|
||||||
|
|
||||||
|
def test_encrypt_is_not_reversible_by_eye():
|
||||||
|
secret = "sk-proj-abcdef1234567890"
|
||||||
|
assert secret not in encrypt(secret)
|
||||||
|
|
||||||
|
|
||||||
|
def test_encrypt_is_randomised():
|
||||||
|
"""Fernet includes a random IV, so the same input must not repeat."""
|
||||||
|
assert encrypt("same") != encrypt("same")
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_secret_stays_empty():
|
||||||
|
"""Endpoints that need no key store nothing, not an encrypted blank."""
|
||||||
|
assert encrypt("") == ""
|
||||||
|
assert decrypt("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_decrypt_fails_soft_on_garbage():
|
||||||
|
"""An unreadable value means the secret key changed. The admin UI has to
|
||||||
|
stay usable so the key can simply be re-entered."""
|
||||||
|
assert decrypt("not-a-valid-token") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_keeps_enough_to_identify_but_not_to_use():
|
||||||
|
masked = mask("sk-proj-abcdef1234567890")
|
||||||
|
assert masked.startswith("sk-")
|
||||||
|
assert masked.endswith("7890")
|
||||||
|
assert "abcdef" not in masked
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_hides_short_secrets_entirely():
|
||||||
|
assert set(mask("short")) == {"*"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_round_trip():
|
||||||
|
stored = hash_password("speak-friend")
|
||||||
|
assert verify_password("speak-friend", stored)
|
||||||
|
assert not verify_password("Speak-Friend", stored)
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_hash_is_salted():
|
||||||
|
assert hash_password("same") != hash_password("same")
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_a_corrupt_hash_instead_of_raising():
|
||||||
|
assert not verify_password("anything", "not-a-hash")
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_passwords_are_rejected():
|
||||||
|
assert validate_password("short") is not None
|
||||||
|
assert validate_password("long-enough-password") is None
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Markdown rendering and sanitisation.
|
||||||
|
|
||||||
|
Model output is untrusted input: it routinely contains HTML and a model can be
|
||||||
|
talked into emitting a script tag. These are the tests that keep that boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from lembas.services.markdown import escape_text, render_markdown
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_formatting():
|
||||||
|
html = render_markdown("Some **bold** and *italic* text.")
|
||||||
|
assert "<strong>bold</strong>" in html
|
||||||
|
assert "<em>italic</em>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_tags_are_stripped():
|
||||||
|
html = render_markdown("Hello <script>alert('xss')</script> world")
|
||||||
|
assert "<script" not in html
|
||||||
|
assert "alert" not in html or "<script" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_javascript_urls_never_become_links():
|
||||||
|
"""markdown-it refuses the scheme and leaves the source as literal text, so
|
||||||
|
the guarantee to assert is that no anchor is produced -- not that the
|
||||||
|
substring is absent, which it legitimately is not."""
|
||||||
|
html = render_markdown("[click me](javascript:alert(1))")
|
||||||
|
assert "<a " not in html
|
||||||
|
assert 'href="javascript:' not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_javascript_urls_in_raw_html_anchors_are_stripped():
|
||||||
|
html = render_markdown('<a href="javascript:alert(1)">click</a>')
|
||||||
|
assert 'href="javascript:' not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_handlers_are_stripped():
|
||||||
|
html = render_markdown('<img src="x" onerror="alert(1)">')
|
||||||
|
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 <pre><code> unless the
|
||||||
|
# fence rule is replaced outright. This is the regression guard.
|
||||||
|
assert "<pre><code" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_block_language_label():
|
||||||
|
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<script>alert(1)</script>\n```")
|
||||||
|
assert "<script>" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_tables_render():
|
||||||
|
html = render_markdown("| a | b |\n|---|---|\n| 1 | 2 |")
|
||||||
|
assert "<table>" in html and "<td>1</td>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_urls_are_linkified():
|
||||||
|
assert "<a href=" in render_markdown("see https://example.com for more")
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_input():
|
||||||
|
assert render_markdown("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_text_handles_structural_characters():
|
||||||
|
assert escape_text("<b>hi</b>") == "<b>hi</b>"
|
||||||
|
assert escape_text("a & b") == "a & b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_text_is_chunk_safe():
|
||||||
|
"""Streaming escapes each token as it arrives, so escaping the pieces must
|
||||||
|
equal escaping the whole -- otherwise a stream would diverge from the final
|
||||||
|
rendering."""
|
||||||
|
whole = "<a>&</a> text"
|
||||||
|
chunks = ["<a", ">&<", "/a> ", "text"]
|
||||||
|
assert "".join(escape_text(c) for c in chunks) == escape_text(whole)
|
||||||
Reference in New Issue
Block a user