Users, groups, permissions, model settings and reasoning display

Four features, plus the schema machinery they needed.

**Schema sync.** The first live instance had data in it, and create_all
only creates missing *tables* -- a new column silently never appeared.
db/migrations.py now diffs the declared models against the database and
ALTER TABLE ... ADD COLUMN for what is missing, deriving a backfill
default from the column type (SQLite refuses a NOT NULL column without
one, and a Python-side `default=dict` cannot be expressed in DDL).
Verified against a copy of the live database: eight changes applied, all
rows preserved, second run a no-op. Renames, drops and retypes are still
manual and say so.

**Permissions.** A flat set of named booleans: an instance baseline
widened by each group the user belongs to. A group grants and never
denies -- with denies, "why can this user not do X" cannot be answered
without simulating every group. Admins bypass entirely, because an admin
can grant it back to themselves in two clicks and pretending otherwise
is theatre. Model *access* is separate: public, or granted to groups.
The picker is not the boundary -- switching a chat to a model you cannot
reach is a 403.

**Model settings.** Ordering, pinned-first, an instance default and a
per-user default, display names, descriptions, capability flags, and
uploaded images. Images are stored and served locally rather than by
URL: a remote URL makes every page render a request to a third party.
Uploads are validated by magic number, not the declared content type,
and stored under a random name. Models with no image get a generated
initial whose hue is derived from the model id, so it is stable.

**Reasoning display.** Streams into its own collapsible block above the
answer, labelled "Thought for 14 seconds", collapsed once finished, and
never replayed as context on the next turn. Two sources: the
reasoning_content delta field, and <think> tags inline in content -- the
latter needs a streaming splitter because the tags arrive split across
chunks. Models emitting no reasoning show nothing, via a :has() rule
rather than JavaScript. Verified against qwen35-9b on llama-swap: 694
reasoning events, 52 answer tokens, cleanly separated.

Two bugs found and fixed while testing:

- A bare `Mapped[list]` relationship is treated by SQLAlchemy as a scalar
  and returns None instead of []. It needs the element type.
- FastAPI substitutes the default for an empty form value, so with
  `x: str | None = Form(None)` a submitted `x=` is indistinguishable from
  an absent field. That silently broke clearing a system prompt or a
  temperature. update_chat now reads the raw form and checks key presence.

143 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 11:49:32 +02:00
parent ba2fb1e13d
commit d6c87ac811
37 changed files with 2887 additions and 152 deletions
+53 -18
View File
@@ -19,7 +19,7 @@ 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
pytest # 143 tests, ~5s
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
@@ -38,9 +38,10 @@ redesign, not a tweak.
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.
4. **Additive-only schema changes.** SQLite only, no Alembic. `init_db()` runs
`db/migrations.py:sync_schema()`, which creates missing tables *and* adds
missing columns by diffing the models against the database. Renames, drops
and retypes are still manual. 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
@@ -71,18 +72,24 @@ src/lembas/
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
admin.py connections + instance settings
admin_models.py model ordering, defaults, images, access
admin_users.py users, groups, permissions
preferences.py per-user theme, default model, password
db/
base.py Base, UUID/Timestamp mixins
session.py engine, SQLite pragmas, init_db, session_scope
migrations.py additive schema sync (tables + columns)
models/ user, chat, connection, setting
security/ passwords (argon2), sessions
security/ passwords (argon2), sessions, permissions
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
reasoning.py splits thinking from the answer
settings_store.py runtime instance settings
uploads.py validated image storage
sse.py event framing
web/
templating.py render() -- always use this, not TemplateResponse
@@ -138,20 +145,46 @@ settings an admin edits at runtime, stored in the `settings` table. Environment
variables seed the latter as an *initial* value only — once stored, the database
wins, or a toggle in the UI would silently revert on the next restart.
**Permissions are a union, and admins bypass them.** `security/permissions.py`
resolves a baseline (instance setting) widened by each group. A group grants;
it never denies — otherwise "why can this user not do X" needs a simulation of
every group to answer. Model *access* is separate: `models_visible_to()`.
**FastAPI cannot tell an empty form field from an absent one.** With
`x: str | None = Form(None)`, a submitted `x=` arrives as `None`, so "clear this
field" is indistinguishable from "leave it alone". `api/chats.py:update_chat`
reads `await request.form()` and checks key presence instead. Anything with a
clearable field must do the same.
**`Mapped[list]` without an element type is not a collection.** SQLAlchemy
treats a bare `Mapped[list]` as a scalar and hands back `None` instead of `[]`.
Always write `Mapped[list[Group]]`, with a `TYPE_CHECKING` import if the class
lives in another module.
**Reasoning arrives two ways.** A `reasoning_content` delta field (llama.cpp,
llama-swap, vLLM) or `<think>` tags inline in `content` (Ollama and friends).
`services/reasoning.py` handles the second with a streaming splitter, because
the tags arrive split across chunks. Reasoning is stored in `Message.reasoning`
and is deliberately **not** replayed as context on the next turn.
**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.
There is no Alembic, but there *is* `db/migrations.py`. It compares the declared
models against the live database and issues `ALTER TABLE ... ADD COLUMN` for
anything missing, so adding a column to a model is free: restart and it appears,
with existing rows backfilled from a type-derived default.
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.
It cannot rename, drop or retype a column, or add a UNIQUE/PRIMARY KEY to an
existing table — SQLite mostly cannot do those with ALTER TABLE either. Those
need the create-copy-swap dance by hand; record them in `MANUAL_STEPS` so a
failure has somewhere to point.
Because the runner exists, forward-looking columns are cheap now. `Message.parent_id`
and `content_parts_json` (branching, multimodal) predate it and are still unread.
## Artwork
@@ -182,7 +215,9 @@ notes describe the 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.
File upload / vision / PDFs, built-in tools + admin tool settings, custom tools
and MCP, agentic execution (local subprocess and SSH connection profiles), image
generation. Nav entries mark where each one goes.
`Model.capabilities_json` already carries `vision` and `tools` flags that
nothing reads yet — they are admin overrides waiting for those features.
+17 -7
View File
@@ -31,22 +31,30 @@ runtime. Clone it, `pip install -e .`, run it.
**Working now**
- **Chats** — streaming replies, Markdown with server-side syntax highlighting,
copy and regenerate, automatic chat titles
copy and regenerate, automatic chat titles, per-chat system prompt and
sampling settings
- **Reasoning display** — thinking from reasoning models streams into its own
collapsible block, labelled with how long it took, and is never replayed as
context
- **Folders** — arbitrarily nested, delete a folder without losing the chats
inside it
- **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp,
llama-swap, Ollama or OpenRouter; models are discovered and cached
- **Model settings** — ordering, pinned models, an instance default and a
per-user default, custom names and descriptions, uploaded model images
- **Users, groups & permissions** — per-group grants that union rather than
override, and model access restricted to chosen groups
- **Accounts** — first account becomes the administrator, argon2 password
hashing, revocable server-side sessions, self-service password change
hashing, revocable server-side sessions, self-service password change,
admin-managed accounts
- **Admin settings** — open or close registration from the UI, stored in the
database and effective immediately
- **Two themes** — *Moria* (dark) and *Shire* (light), switchable per user
**Planned**
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.
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
@@ -132,8 +140,10 @@ 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`.
There is no Alembic. The schema is SQLite-only and synchronised at startup:
missing tables and missing columns are added automatically, so adding a field to
a model needs nothing but a restart. Renames, drops and retypes are still manual
— see `CLAUDE.md`.
## Artwork
+6 -17
View File
@@ -193,12 +193,17 @@ async def _refresh_models(db: DBSession, connection: Connection) -> tuple[int, s
existing = {model.model_id: model for model in connection.models}
seen: set[str] = set()
# New models land after everything already ordered, rather than all at
# position 0 where they would sort by id and shuffle the existing list.
next_position = (db.scalar(select(func.coalesce(func.max(Model.position), -1))) or -1) + 1
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))
db.add(Model(connection_id=connection.id, model_id=model_id, position=next_position))
next_position += 1
# Models that vanished upstream are dropped, so the picker never offers
# something the endpoint will reject.
@@ -219,19 +224,3 @@ async def delete_connection(db: Db, user: AdminUser, connection_id: str) -> Resp
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)
+212
View File
@@ -0,0 +1,212 @@
"""Model administration: ordering, defaults, images, access and capabilities."""
from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile, status
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import AdminUser, Db, RequiredUser
from lembas.db.models import Connection, Group, Model
from lembas.services import settings_store, uploads
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(tags=["admin-models"])
CAPABILITIES = ("reasoning", "vision", "tools")
def _model(db: DBSession, model_id: str) -> Model:
model = db.get(Model, model_id)
if model is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")
return model
def _ordered(db: DBSession) -> list[Model]:
return list(
db.scalars(
select(Model).join(Connection).order_by(Model.position, Model.model_id)
)
)
def _renumber(db: DBSession) -> None:
"""Rewrite positions to 0..n-1.
Keeps the numbers dense so a move is always a swap with a neighbour, and
stops repeated reordering drifting into large sparse values.
"""
for index, model in enumerate(_ordered(db)):
model.position = index
db.commit()
# --- The admin page ----------------------------------------------------------
@router.get("/admin/models")
async def models_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
models = _ordered(db)
return render(
request,
"admin/models.html",
{
"models": models,
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"connections": list(db.scalars(select(Connection).order_by(Connection.name))),
"default_model": settings_store.get(db, "default_model") or "",
"capabilities": CAPABILITIES,
"saved": saved,
},
)
@router.post("/admin/models/{model_id}")
async def update_model(
db: Db,
user: AdminUser,
model_id: str,
display_name: str = Form(""),
description: str = Form(""),
enabled: bool = Form(False),
pinned: bool = Form(False),
public: bool = Form(False),
group_ids: list[str] = Form(default=[]),
capability: list[str] = Form(default=[]),
) -> Response:
model = _model(db, model_id)
model.display_name = display_name.strip()[:300]
model.description = description.strip()[:2000]
model.enabled = enabled
model.pinned = pinned
model.public = public
# Absent checkboxes are simply missing from a form post, so the submitted
# list IS the complete new state -- rebuild rather than merge.
model.capabilities_json = {name: (name in capability) for name in CAPABILITIES}
if public:
# Group rows would be dead weight and misleading in the UI.
model.groups = []
else:
model.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
db.commit()
log.info("model %s updated by %s", model.model_id, user.email)
return RedirectResponse("/admin/models?saved=Model+saved.", status_code=303)
@router.post("/admin/models/{model_id}/move")
async def move_model(
db: Db, user: AdminUser, model_id: str, direction: str = Form(...)
) -> Response:
"""Swap a model with its neighbour."""
model = _model(db, model_id)
ordered = _ordered(db)
index = next((i for i, m in enumerate(ordered) if m.id == model.id), None)
if index is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")
target = index - 1 if direction == "up" else index + 1
if 0 <= target < len(ordered):
ordered[index], ordered[target] = ordered[target], ordered[index]
for position, item in enumerate(ordered):
item.position = position
db.commit()
return RedirectResponse("/admin/models", status_code=303)
@router.post("/admin/models/{model_id}/default")
async def set_default_model(db: Db, user: AdminUser, model_id: str) -> Response:
"""Make a model the instance default for new chats."""
model = _model(db, model_id)
settings_store.update(db, {"default_model": model.model_id})
log.info("default model set to %s by %s", model.model_id, user.email)
return RedirectResponse(
f"/admin/models?saved={model.label}+is+now+the+default.", status_code=303
)
@router.post("/admin/models/{model_id}/image")
async def upload_model_image(
db: Db, user: AdminUser, model_id: str, image: UploadFile = File(...)
) -> Response:
model = _model(db, model_id)
payload = await image.read()
try:
filename = uploads.save_model_image(payload, image.content_type or "")
except uploads.UploadError as exc:
return RedirectResponse(f"/admin/models?saved={exc}", status_code=303)
# Remove the old file rather than orphaning it in the uploads directory.
if model.image_path:
uploads.delete_model_image(model.image_path)
model.image_path = filename
db.commit()
return RedirectResponse("/admin/models?saved=Image+updated.", status_code=303)
@router.post("/admin/models/{model_id}/image/delete")
async def delete_model_image(db: Db, user: AdminUser, model_id: str) -> Response:
model = _model(db, model_id)
if model.image_path:
uploads.delete_model_image(model.image_path)
model.image_path = ""
db.commit()
return RedirectResponse("/admin/models?saved=Image+removed.", status_code=303)
@router.post("/admin/models/bulk")
async def bulk_models(
db: Db, user: AdminUser, action: str = Form(...), model_ids: list[str] = Form(default=[])
) -> Response:
"""Enable or disable several models at once.
A freshly refreshed connection can advertise dozens of models; turning them
off one at a time is not a reasonable way to spend an afternoon.
"""
models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
for model in models:
if action == "enable":
model.enabled = True
elif action == "disable":
model.enabled = False
elif action == "public":
model.public = True
model.groups = []
elif action == "private":
model.public = False
db.commit()
_renumber(db)
return RedirectResponse(
f"/admin/models?saved={len(models)}+model(s)+updated.", status_code=303
)
# --- Serving model images ----------------------------------------------------
@router.get("/uploads/models/{filename}")
async def model_image(user: RequiredUser, filename: str) -> Response:
"""Serve a stored model avatar.
Behind the auth guard: these are instance assets, not public files, and
the path resolution in uploads refuses anything outside the directory.
"""
path = uploads.model_image_path(filename)
if path is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such image.")
return FileResponse(
path,
media_type=uploads.media_type_for(filename),
# Filenames are random and content-addressed in practice, so a long
# cache is safe: a new image gets a new name.
headers={"Cache-Control": "private, max-age=604800"},
)
+268
View File
@@ -0,0 +1,268 @@
"""User and group administration."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import AdminUser, Db
from lembas.db.models import ROLE_ADMIN, ROLE_PENDING, ROLE_USER, Group, Model, User
from lembas.security import permissions
from lembas.security.passwords import hash_password, validate_password
from lembas.security.sessions import revoke_all_for_user
from lembas.services import settings_store
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin-users"])
ROLES = (ROLE_ADMIN, ROLE_USER, ROLE_PENDING)
def _user(db: DBSession, user_id: str) -> User:
found = db.get(User, user_id)
if found is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That user no longer exists.")
return found
def _group(db: DBSession, group_id: str) -> Group:
found = db.get(Group, group_id)
if found is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That group no longer exists.")
return found
def _admin_count(db: DBSession) -> int:
return db.scalar(
select(func.count()).select_from(User).where(User.role == ROLE_ADMIN, User.active.is_(True))
)
def _would_orphan_the_instance(db: DBSession, user: User) -> bool:
"""True if changing this user would leave nobody able to administer.
An instance with no active administrator can only be recovered from the
command line, so every path that could cause it is blocked in the UI.
"""
return user.role == ROLE_ADMIN and user.active and _admin_count(db) <= 1
# --- Users -------------------------------------------------------------------
@router.get("/users")
async def users_page(request: Request, db: Db, user: AdminUser, q: str = "", saved: str = ""):
query = select(User).order_by(User.created_at)
if q.strip():
pattern = f"%{q.strip()}%"
query = query.where(or_(User.name.ilike(pattern), User.email.ilike(pattern)))
return render(
request,
"admin/users.html",
{
"users": list(db.scalars(query)),
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"roles": ROLES,
"q": q,
"saved": saved,
"admin_count": _admin_count(db),
},
)
@router.post("/users")
async def create_user(
db: Db,
user: AdminUser,
name: str = Form(...),
email: str = Form(...),
password: str = Form(...),
role: str = Form(ROLE_USER),
) -> Response:
"""Create an account directly, without going through registration."""
email = email.strip().lower()
if (problem := validate_password(password)) is not None:
return RedirectResponse(f"/admin/users?saved={problem}", status_code=303)
if db.scalar(select(User).where(User.email == email)) is not None:
return RedirectResponse(
"/admin/users?saved=That+email+is+already+registered.", status_code=303
)
db.add(
User(
name=name.strip()[:120] or email,
email=email,
password_hash=hash_password(password),
role=role if role in ROLES else ROLE_USER,
)
)
db.commit()
log.info("%s created account %s", user.email, email)
return RedirectResponse(f"/admin/users?saved=Created+{email}.", status_code=303)
@router.post("/users/{user_id}")
async def update_user(
db: Db,
user: AdminUser,
user_id: str,
name: str = Form(...),
role: str = Form(ROLE_USER),
active: bool = Form(False),
group_ids: list[str] = Form(default=[]),
) -> Response:
target = _user(db, user_id)
losing_admin = target.role == ROLE_ADMIN and (role != ROLE_ADMIN or not active)
if losing_admin and _would_orphan_the_instance(db, target):
return RedirectResponse(
"/admin/users?saved=That+is+the+only+administrator.+Promote+someone+else+first.",
status_code=303,
)
target.name = name.strip()[:120] or target.name
target.role = role if role in ROLES else target.role
target.active = active
target.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
# A deactivated or demoted user must lose their live sessions immediately,
# otherwise the change only takes effect when their cookie happens to expire.
if not active:
revoke_all_for_user(db, target)
db.commit()
log.info("%s updated account %s (role=%s active=%s)", user.email, target.email, role, active)
return RedirectResponse(f"/admin/users?saved=Saved+{target.email}.", status_code=303)
@router.post("/users/{user_id}/password")
async def reset_password(
db: Db, user: AdminUser, user_id: str, password: str = Form(...)
) -> Response:
target = _user(db, user_id)
if (problem := validate_password(password)) is not None:
return RedirectResponse(f"/admin/users?saved={problem}", status_code=303)
target.password_hash = hash_password(password)
db.commit()
# Everywhere that account was signed in is now signed out. An admin reset
# usually means the account is compromised or the person is gone.
revoke_all_for_user(db, target)
log.info("%s reset the password for %s", user.email, target.email)
return RedirectResponse(
f"/admin/users?saved=Password+reset+for+{target.email}.+Sessions+revoked.",
status_code=303,
)
@router.post("/users/{user_id}/delete")
async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
target = _user(db, user_id)
if target.id == user.id:
return RedirectResponse(
"/admin/users?saved=You+cannot+delete+your+own+account.", status_code=303
)
if _would_orphan_the_instance(db, target):
return RedirectResponse(
"/admin/users?saved=That+is+the+only+administrator.", status_code=303
)
email = target.email
# Chats and folders cascade; that is the point of deleting an account.
db.delete(target)
db.commit()
log.info("%s deleted account %s", user.email, email)
return RedirectResponse(f"/admin/users?saved=Deleted+{email}.", status_code=303)
# --- Groups ------------------------------------------------------------------
@router.get("/groups")
async def groups_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
return render(
request,
"admin/groups.html",
{
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"users": list(db.scalars(select(User).order_by(User.name))),
"models": list(db.scalars(select(Model).order_by(Model.position, Model.model_id))),
"permission_groups": permissions.permission_groups(),
"baseline": permissions.baseline_permissions(db),
"saved": saved,
},
)
@router.post("/groups")
async def create_group(db: Db, user: AdminUser, name: str = Form(...)) -> Response:
name = name.strip()[:120]
if not name:
return RedirectResponse("/admin/groups?saved=A+group+needs+a+name.", status_code=303)
if db.scalar(select(Group).where(Group.name == name)) is not None:
return RedirectResponse(
"/admin/groups?saved=A+group+with+that+name+already+exists.", status_code=303
)
db.add(Group(name=name))
db.commit()
log.info("%s created group %s", user.email, name)
return RedirectResponse(f"/admin/groups?saved=Created+{name}.", status_code=303)
@router.post("/groups/{group_id}")
async def update_group(
db: Db,
user: AdminUser,
group_id: str,
name: str = Form(...),
description: str = Form(""),
permission: list[str] = Form(default=[]),
user_ids: list[str] = Form(default=[]),
model_ids: list[str] = Form(default=[]),
) -> Response:
group = _group(db, group_id)
group.name = name.strip()[:120] or group.name
group.description = description.strip()[:1000]
# The submitted checkbox list is the complete new state; absent means the
# group does not grant that permission, not that it denies it.
group.permissions_json = {key: True for key in permission if key in permissions.PERMISSION_KEYS}
group.users = list(db.scalars(select(User).where(User.id.in_(user_ids or []))))
group.models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
db.commit()
log.info("%s updated group %s", user.email, group.name)
return RedirectResponse(f"/admin/groups?saved=Saved+{group.name}.", status_code=303)
@router.post("/groups/{group_id}/delete")
async def delete_group(db: Db, user: AdminUser, group_id: str) -> Response:
group = _group(db, group_id)
name = group.name
# Members and model links go with it; the users themselves are untouched.
db.delete(group)
db.commit()
log.info("%s deleted group %s", user.email, name)
return RedirectResponse(f"/admin/groups?saved=Deleted+{name}.", status_code=303)
@router.post("/permissions/defaults")
async def save_baseline(
db: Db, user: AdminUser, permission: list[str] = Form(default=[])
) -> Response:
"""The permissions every user has before any group widens them."""
settings_store.update(
db,
{
"default_permissions": {
key: (key in permission) for key in permissions.PERMISSION_KEYS
}
},
)
log.info("%s changed the baseline permissions", user.email)
return RedirectResponse("/admin/groups?saved=Default+permissions+saved.", status_code=303)
+149 -26
View File
@@ -4,19 +4,27 @@ from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import AsyncIterator
from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi import APIRouter, Depends, 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.api.deps import Db, RequiredUser, require_permission
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
from lembas.db.session import session_scope
from lembas.security import permissions
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.llm.openai_client import (
LLMError,
delta_reasoning,
delta_text,
stream_chat,
)
from lembas.services.markdown import escape_text, render_markdown
from lembas.services.reasoning import REASONING, ReasoningSplitter
from lembas.web.templating import render, templates
log = logging.getLogger(__name__)
@@ -33,9 +41,9 @@ def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
return chat
@router.post("")
@router.post("", dependencies=[Depends(require_permission("chat.create"))])
async def create_chat(db: Db, user: RequiredUser, folder_id: str = Form("")) -> Response:
chosen = chat_service.default_model(db)
chosen = chat_service.default_model(db, user)
chat = Chat(
user_id=user.id,
folder_id=folder_id or None,
@@ -130,7 +138,9 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
be closed by the time the first token arrives.
"""
accumulated: list[str] = []
thinking: list[str] = []
error: str | None = None
reasoning_ms = 0
with session_scope() as db:
chat = db.get(Chat, chat_id)
@@ -140,6 +150,12 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
return
first_user_text = ""
# Handles models that emit <think> tags inline in content rather than
# using the reasoning_content field.
splitter = ReasoningSplitter()
started = time.monotonic()
reasoning_started: float | None = None
try:
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
payload = chat_service.build_request(db, chat, upto=message)
@@ -149,15 +165,43 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
)
async for chunk in stream_chat(endpoint, payload):
# A dedicated reasoning field is unambiguous; take it as-is.
thought = delta_reasoning(chunk)
if thought:
if reasoning_started is None:
reasoning_started = time.monotonic()
thinking.append(thought)
yield sse.event("reasoning", escape_text(thought))
await asyncio.sleep(0)
text = delta_text(chunk)
if not text:
continue
accumulated.append(text)
yield sse.event("token", escape_text(text))
for kind, piece in splitter.feed(text):
if kind == REASONING:
if reasoning_started is None:
reasoning_started = time.monotonic()
thinking.append(piece)
yield sse.event("reasoning", escape_text(piece))
else:
# First answer token ends the thinking phase.
if reasoning_started is not None and not reasoning_ms:
reasoning_ms = int((time.monotonic() - reasoning_started) * 1000)
accumulated.append(piece)
yield sse.event("token", escape_text(piece))
# Hand control back so the event is flushed rather than
# batched behind a fast generator.
await asyncio.sleep(0)
for kind, piece in splitter.flush():
if kind == REASONING:
thinking.append(piece)
yield sse.event("reasoning", escape_text(piece))
else:
accumulated.append(piece)
yield sse.event("token", escape_text(piece))
except LLMError as exc:
error = exc.message
log.info("generation failed for chat %s: %s", chat_id, exc.message)
@@ -165,6 +209,7 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
# 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.reasoning = "".join(thinking)
message.complete = True
db.commit()
raise
@@ -172,9 +217,22 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
error = "Something went wrong while generating this reply."
log.exception("unexpected generation failure for chat %s: %s", chat_id, exc)
if reasoning_started is not None and not reasoning_ms:
# Reasoning ran to the end without an answer following it.
reasoning_ms = int((time.monotonic() - reasoning_started) * 1000)
message.content = "".join(accumulated)
message.reasoning = "".join(thinking)
message.reasoning_ms = reasoning_ms
message.error = error or ""
message.complete = True
log.debug(
"chat %s: %d chars answer, %d chars reasoning, %.1fs total",
chat_id,
len(message.content),
len(message.reasoning),
time.monotonic() - started,
)
if not chat.title_generated and (accumulated or error):
chat.title = (
@@ -208,38 +266,103 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
@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)
async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response:
"""Partially update a chat.
if title is not None:
cleaned = title.strip()[:300]
The raw form is read rather than declaring Form() parameters because
FastAPI substitutes the default for an empty form value, which makes
"field absent" and "field submitted empty" indistinguishable. That
difference is exactly what this endpoint needs: an empty system prompt or
temperature means *clear it*, not *leave it alone*.
"""
chat = _owned_chat(db, chat_id, user.id)
allowed = permissions.resolve(db, user)
form = await request.form()
if "title" in form:
cleaned = str(form["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 "folder_id" in form:
chat.folder_id = str(form["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
model_id = str(form.get("model_id", "")).strip()
if model_id:
if not allowed.get("chat.model_select"):
raise HTTPException(
status.HTTP_403_FORBIDDEN, "You may not change the model for a chat."
)
chat.connection_id = match.connection_id if match else None
# Checked against what this user can reach, not merely what exists --
# otherwise the picker is advisory and a crafted request bypasses it.
match = next(
(m for m in chat_service.available_models(db, user) if m.model_id == model_id),
None,
)
if match is None:
raise HTTPException(status.HTTP_403_FORBIDDEN, "That model is not available to you.")
chat.model_id = model_id
chat.connection_id = match.connection_id
if "system_prompt" in form:
if not allowed.get("chat.system_prompt"):
raise HTTPException(
status.HTTP_403_FORBIDDEN, "You may not set a system prompt."
)
chat.system_prompt = str(form["system_prompt"]).strip()[:8000]
submitted_params = {name: form[name] for name in _PARAM_RANGES if name in form}
if submitted_params:
if not allowed.get("chat.params"):
raise HTTPException(
status.HTTP_403_FORBIDDEN, "You may not change sampling parameters."
)
chat.params_json = {
**(chat.params_json or {}),
**_clean_params(**{k: str(v) for k, v in submitted_params.items()}),
}
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{chat_id}")
# Bounds are the ones every provider agrees on. Out-of-range values are
# dropped rather than clamped: silently changing what someone typed is worse
# than ignoring it, and the form shows what actually stuck on reload.
_PARAM_RANGES: dict[str, tuple[type, float, float]] = {
"temperature": (float, 0.0, 2.0),
"top_p": (float, 0.0, 1.0),
"max_tokens": (int, 1, 1_000_000),
}
def _clean_params(**submitted: str | None) -> dict[str, float | int | None]:
"""Parse sampling parameters, dropping anything unusable.
An empty string means "unset this and let the provider default apply", so
it maps to None rather than being ignored.
"""
cleaned: dict[str, float | int | None] = {}
for name, raw in submitted.items():
if raw is None:
continue
if not raw.strip():
cleaned[name] = None
continue
caster, low, high = _PARAM_RANGES[name]
try:
value = caster(raw)
except (TypeError, ValueError):
continue
if low <= value <= high:
cleaned[name] = value
return cleaned
@router.delete("/{chat_id}", dependencies=[Depends(require_permission("chat.delete"))])
async def delete_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
chat = _owned_chat(db, chat_id, user.id)
db.delete(chat)
+21
View File
@@ -78,6 +78,27 @@ def require_admin(user: RequiredUser) -> User:
AdminUser = Annotated[User, Depends(require_admin)]
def require_permission(key: str):
"""Dependency factory guarding a route behind a named permission.
@router.post("", dependencies=[Depends(require_permission("chat.create"))])
Administrators always pass; see lembas.security.permissions for why.
"""
def guard(db: Db, user: RequiredUser) -> User:
from lembas.security import permissions
if not permissions.has(db, user, key):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to do that.",
)
return user
return guard
def is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request") == "true"
+8 -3
View File
@@ -2,13 +2,18 @@
from __future__ import annotations
from fastapi import APIRouter, Form, HTTPException, Response, status
from fastapi import APIRouter, Depends, Form, HTTPException, Response, status
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.db.models import Folder
router = APIRouter(prefix="/api/folders", tags=["folders"])
# Every route here manages folders, so the guard belongs on the router.
router = APIRouter(
prefix="/api/folders",
tags=["folders"],
dependencies=[Depends(require_permission("folder.manage"))],
)
MAX_DEPTH = 8
+29 -4
View File
@@ -9,6 +9,7 @@ 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.security import permissions
from lembas.services import chat as chat_service
from lembas.services.markdown import render_markdown
from lembas.web.templating import render
@@ -16,6 +17,26 @@ from lembas.web.templating import render
router = APIRouter(tags=["pages"])
def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
"""Model lists and permissions every chat page needs.
Pinned and unpinned are split here rather than in the template so the
picker's optgroups stay a plain loop.
"""
models = chat_service.available_models(db, user)
current = next((m for m in models if m.model_id == chat.model_id), None) if chat else None
pinned = [m for m in models if m.pinned]
return {
"models": models,
"pinned_models": pinned,
# Excludes the pinned ones: they already have their own optgroup, and
# listing a model twice gives the <select> two options with the same
# value, both marked selected.
"other_models": [m for m in models if not m.pinned] if pinned else models,
"current_model": current,
}
def _sidebar_context(db: DBSession, user: User) -> dict:
"""Folder tree plus the chats that belong to no folder.
@@ -40,7 +61,11 @@ def _sidebar_context(db: DBSession, user: User) -> dict:
.order_by(Chat.pinned.desc(), Chat.updated_at.desc())
)
)
return {"folders": folders, "unfiled_chats": unfiled}
return {
"folders": folders,
"unfiled_chats": unfiled,
"can": permissions.resolve(db, user),
}
@router.get("/")
@@ -56,7 +81,7 @@ async def chat_index(request: Request, db: Db, user: RequiredUser):
{
"chat": None,
"messages": [],
"models": chat_service.available_models(db),
**_chat_context(db, user, None),
**_sidebar_context(db, user),
},
)
@@ -90,7 +115,7 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
"chat": chat,
"messages": messages,
"bodies": bodies,
"models": chat_service.available_models(db),
**_chat_context(db, user, chat),
**_sidebar_context(db, user),
},
)
@@ -111,9 +136,9 @@ async def settings_page(
"settings.html",
{
"chat": None,
"models": chat_service.available_models(db),
"error": error,
"saved": saved,
**_chat_context(db, user, None),
**_sidebar_context(db, user),
},
)
+29
View File
@@ -37,6 +37,35 @@ async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=Tru
return {"ok": True, "theme": theme}
@router.post("/default-model")
async def set_default_model(
db: Db, user: RequiredUser, model_id: str = Form("")
) -> Response:
"""Choose which model new chats start with.
An empty value clears the choice and falls back to the instance default.
Validated against what this user can actually reach, so a model they lose
access to cannot linger as a preference that silently fails later.
"""
from lembas.security import permissions
model_id = model_id.strip()
if model_id and not permissions.can_use_model(db, user, model_id):
return RedirectResponse(
"/settings?error=That+model+is+not+available+to+you.", status_code=303
)
settings_map = {**(user.settings_json or {})}
if model_id:
settings_map["default_model"] = model_id
else:
settings_map.pop("default_model", None)
user.settings_json = settings_map
db.commit()
return RedirectResponse("/settings?saved=Default+model+updated.", status_code=303)
@router.post("/password")
async def change_password(
request: Request,
+133
View File
@@ -0,0 +1,133 @@
"""Additive schema synchronisation.
This project has no Alembic, by design: it is SQLite-only and the schema is
created at startup. That was fine until the first live instance had data in it,
at which point adding a column to a model stopped being free -- ``create_all``
only creates missing *tables*, so a new column silently never appears and every
query mentioning it fails.
What this module does instead is derive the migration from the models: compare
each table's declared columns against what the database actually has, and
``ALTER TABLE ... ADD COLUMN`` for whatever is missing. That covers new tables
and new columns, which is essentially every schema change this project makes.
What it deliberately does NOT do:
* rename, drop or retype a column
* add a PRIMARY KEY or UNIQUE constraint to an existing table
* backfill anything requiring application logic
SQLite cannot do most of those with ALTER TABLE anyway; they need the
create-copy-swap dance. Anything in that category is a hand-written job and
should be added to MANUAL_STEPS below so it is at least visible.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import Engine, inspect, text
from sqlalchemy.schema import Column, Table
from lembas.db.base import Base
log = logging.getLogger(__name__)
# Schema changes that this module cannot perform. Kept as documentation so a
# failure has somewhere to point rather than being a mystery.
MANUAL_STEPS: list[str] = []
def _literal_default(column: Column) -> str | None:
"""A SQL literal to backfill an existing row's new column with.
SQLite refuses to add a NOT NULL column without a default, and refuses a
non-constant default. Python-side defaults (``default=dict``,
``default=utcnow``) are callables and cannot be expressed in DDL, so the
value is derived from the column type instead. New rows still get the real
Python default; this only fills the rows that already exist.
"""
default = column.default
if default is not None and not default.is_callable and not default.is_clause_element:
value: Any = default.arg
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
escaped = value.replace("'", "''")
return f"'{escaped}'"
affinity = column.type.__class__.__name__.upper()
if "JSON" in affinity:
# MutableList columns must start as [] and MutableDict as {}; guessing
# wrong makes the first read blow up rather than return empty.
python_type = getattr(column.type, "python_type", None)
return "'[]'" if python_type is list else "'{}'"
if "BOOL" in affinity:
return "0"
if any(token in affinity for token in ("INT", "FLOAT", "NUMERIC", "DECIMAL")):
return "0"
if "DATE" in affinity or "TIME" in affinity:
return "CURRENT_TIMESTAMP"
if any(token in affinity for token in ("STRING", "TEXT", "VARCHAR", "CHAR")):
return "''"
return None
def _add_column_sql(table: Table, column: Column, dialect) -> str | None:
type_sql = column.type.compile(dialect)
default = _literal_default(column)
if not column.nullable and default is None:
log.error(
"cannot add NOT NULL column %s.%s: no usable default. Add it by hand.",
table.name,
column.name,
)
return None
parts = [f'ALTER TABLE "{table.name}" ADD COLUMN "{column.name}" {type_sql}']
if not column.nullable:
parts.append("NOT NULL")
if default is not None:
parts.append(f"DEFAULT {default}")
return " ".join(parts)
def sync_schema(engine: Engine) -> list[str]:
"""Bring the database up to the declared schema. Returns what it changed."""
import lembas.db.models # noqa: F401 (registers every table on the metadata)
changes: list[str] = []
inspector = inspect(engine)
known_tables = set(inspector.get_table_names())
for table in Base.metadata.sorted_tables:
if table.name not in known_tables:
changes.append(f"create table {table.name}")
# Creates anything missing; existing tables are left alone.
Base.metadata.create_all(bind=engine)
inspector = inspect(engine)
with engine.begin() as connection:
for table in Base.metadata.sorted_tables:
existing = {col["name"] for col in inspector.get_columns(table.name)}
for column in table.columns:
if column.name in existing:
continue
statement = _add_column_sql(table, column, engine.dialect)
if statement is None:
continue
connection.execute(text(statement))
changes.append(f"add column {table.name}.{column.name}")
log.info("schema: %s", statement)
if changes:
log.info("schema synchronised: %d change(s)", len(changes))
for step in MANUAL_STEPS:
log.warning("manual schema step still required: %s", step)
return changes
+2 -1
View File
@@ -14,7 +14,7 @@ from lembas.db.models.chat import (
Folder,
Message,
)
from lembas.db.models.connection import Connection, Model
from lembas.db.models.connection import Connection, Model, model_groups
from lembas.db.models.setting import Setting
from lembas.db.models.user import (
ROLE_ADMIN,
@@ -38,6 +38,7 @@ __all__ = [
"Group",
"Message",
"Model",
"model_groups",
"Session",
"Setting",
"User",
+8
View File
@@ -101,6 +101,14 @@ class Message(UUIDPrimaryKey, Timestamps, Base):
# Plain-text messages leave this empty and use `content`.
content_parts_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
# A reasoning model's visible thinking, kept separate from the answer so it
# can be collapsed, and so it is never fed back as context on the next turn
# -- providers expect the answer alone, and replaying the thinking both
# wastes the window and degrades the reply.
reasoning: Mapped[str] = mapped_column(Text, default="")
# Milliseconds spent producing the reasoning, for the "Thought for Xs" label.
reasoning_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
model_id: Mapped[str] = mapped_column(String(300), default="")
tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+53 -3
View File
@@ -3,14 +3,38 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from typing import TYPE_CHECKING, Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
Table,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict
if TYPE_CHECKING:
# Import only for the annotation; at runtime SQLAlchemy resolves the
# name through its own class registry, so there is no import cycle.
from lembas.db.models.user import Group
# Which groups may use a given model. A model with no rows here is reachable
# only by administrators unless it is marked public.
model_groups = Table(
"model_groups",
Base.metadata,
Column("model_id", String(32), ForeignKey("models.id", ondelete="CASCADE"), primary_key=True),
Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
)
class Connection(UUIDPrimaryKey, Timestamps, Base):
"""A configured upstream endpoint speaking the OpenAI HTTP API.
@@ -64,19 +88,45 @@ class Model(UUIDPrimaryKey, Timestamps, Base):
)
model_id: Mapped[str] = mapped_column(String(300), nullable=False)
display_name: Mapped[str] = mapped_column(String(300), default="")
description: Mapped[str] = mapped_column(Text, default="")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Sort order in every picker. Ties fall back to model_id so the order is
# stable rather than whatever SQLite feels like today.
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# Pinned models are offered first, before the full list.
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Public models are usable by anyone; otherwise access comes from `groups`.
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Filename under <data>/uploads/models. Stored rather than a URL so the
# image cannot become a request to a third party on every page render.
image_path: Mapped[str] = mapped_column(String(300), default="")
# Endpoints do not reliably advertise capabilities, so these are admin
# overrides consumed by later passes (vision uploads, tool calling).
# overrides. Recognised keys: vision, tools, reasoning.
capabilities_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# Default sampling params applied to new chats using this model.
params_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
connection: Mapped[Connection] = relationship(back_populates="models")
groups: Mapped[list[Group]] = relationship(
"Group", secondary=model_groups, back_populates="models"
)
@property
def label(self) -> str:
return self.display_name or self.model_id
@property
def supports_reasoning(self) -> bool:
return bool((self.capabilities_json or {}).get("reasoning"))
@property
def initial(self) -> str:
"""First character of the label, for the fallback avatar."""
return (self.label.strip() or "?")[0].upper()
def __repr__(self) -> str:
return f"<Model {self.model_id}>"
+12 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from typing import TYPE_CHECKING, Any
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Table, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -11,6 +11,10 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict
if TYPE_CHECKING:
# Annotation only; SQLAlchemy resolves the real class from its registry.
from lembas.db.models.connection import Model
# Roles are a simple ordered ladder rather than a permission matrix. Groups
# (below) carry finer-grained permissions once the users/groups UI lands.
ROLE_ADMIN = "admin"
@@ -58,9 +62,16 @@ class Group(UUIDPrimaryKey, Timestamps, Base):
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
description: Mapped[str] = mapped_column(Text, default="")
# Only the granted keys need be present. Absent means "no opinion", not
# "deny" -- permissions union across a user's groups. See
# lembas.security.permissions.
permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups")
models: Mapped[list[Model]] = relationship(
"Model", secondary="model_groups", back_populates="groups"
)
class Session(UUIDPrimaryKey, Timestamps, Base):
+8 -7
View File
@@ -10,7 +10,6 @@ from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from lembas.config import settings
from lembas.db.base import Base
log = logging.getLogger(__name__)
@@ -63,15 +62,17 @@ def get_session_factory() -> sessionmaker[Session]:
def init_db() -> None:
"""Create any missing tables.
"""Bring the database up to the declared schema.
This is ``CREATE TABLE IF NOT EXISTS`` only -- it never alters an existing
table. There is no migration tool in this project by design, so changing a
column on a model requires migrating the database by hand.
Creates missing tables and adds missing columns -- see db/migrations.py for
what that does and does not cover. Additive changes need nothing else;
renames, drops and retypes are still a hand job.
"""
import lembas.db.models # noqa: F401 (registers tables on the metadata)
from lembas.db.migrations import sync_schema
Base.metadata.create_all(bind=get_engine())
changes = sync_schema(get_engine())
if changes:
log.info("database schema updated: %s", ", ".join(changes))
log.debug("schema ensured at %s", settings.db_path)
+12 -1
View File
@@ -12,7 +12,16 @@ 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 import (
admin,
admin_models,
admin_users,
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
@@ -67,6 +76,8 @@ def create_app() -> FastAPI:
app.include_router(chats.router)
app.include_router(folders.router)
app.include_router(admin.router)
app.include_router(admin_users.router)
app.include_router(admin_models.router)
register_error_handlers(app)
return app
+146
View File
@@ -0,0 +1,146 @@
"""Permission vocabulary and resolution.
The model is deliberately small: a flat set of named booleans, granted by an
instance-wide baseline and widened by group membership. Permissions are a union
across groups -- being in a second group can only ever grant more, never take
away. That is the behaviour people expect, and the alternative (a deny that
wins) makes "why can this user not do X" unanswerable without simulating every
group.
Administrators bypass the whole thing. There is no permission that can be
withheld from an admin, because an admin can grant it back to themselves in two
clicks; pretending otherwise would be theatre.
Model *access* is separate and lives in models_visible_to(): a permission says
what a user may do, model access says which models they may do it with.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import Connection, Model, User
@dataclass(frozen=True)
class PermissionDef:
key: str
label: str
description: str
default: bool
group: str
# The order here is the order they render in the admin UI.
PERMISSION_DEFS: tuple[PermissionDef, ...] = (
PermissionDef(
"chat.create", "Start chats", "Create new conversations.", True, "Chat"
),
PermissionDef(
"chat.delete", "Delete chats", "Delete their own conversations.", True, "Chat"
),
PermissionDef(
"chat.system_prompt",
"Set system prompts",
"Give an individual chat its own system prompt.",
True,
"Chat",
),
PermissionDef(
"chat.params",
"Adjust sampling",
"Change temperature, top-p and similar per chat.",
False,
"Chat",
),
PermissionDef(
"chat.model_select",
"Choose the model",
"Switch a chat to a different model. Without this, chats use the default.",
True,
"Chat",
),
PermissionDef(
"folder.manage",
"Manage folders",
"Create, rename, nest and delete folders.",
True,
"Workspace",
),
)
PERMISSION_KEYS = tuple(d.key for d in PERMISSION_DEFS)
DEFAULT_PERMISSIONS = {d.key: d.default for d in PERMISSION_DEFS}
def permission_groups() -> dict[str, list[PermissionDef]]:
"""Definitions bucketed by their UI section, preserving declaration order."""
grouped: dict[str, list[PermissionDef]] = {}
for definition in PERMISSION_DEFS:
grouped.setdefault(definition.group, []).append(definition)
return grouped
def baseline_permissions(db: DBSession) -> dict[str, bool]:
"""Instance-wide permissions for a user in no group at all."""
from lembas.services import settings_store
stored = settings_store.get(db, "default_permissions") or {}
return {key: bool(stored.get(key, DEFAULT_PERMISSIONS[key])) for key in PERMISSION_KEYS}
def resolve(db: DBSession, user: User | None) -> dict[str, bool]:
"""Effective permissions for a user."""
if user is None:
return dict.fromkeys(PERMISSION_KEYS, False)
if user.is_admin:
return dict.fromkeys(PERMISSION_KEYS, True)
effective = baseline_permissions(db)
for group in user.groups:
granted = group.permissions_json or {}
for key in PERMISSION_KEYS:
# Union: a group can only widen. Absent means "no opinion", not
# "deny", so a group need only list what it adds.
if granted.get(key):
effective[key] = True
return effective
def has(db: DBSession, user: User | None, key: str) -> bool:
return resolve(db, user).get(key, False)
def models_visible_to(db: DBSession, user: User | None) -> list[Model]:
"""Models a user may start a chat with, in display order.
A model is visible when it is enabled, its connection is enabled, and
either it is public or the user belongs to one of its groups.
"""
query = (
select(Model)
.join(Connection)
.where(Model.enabled.is_(True), Connection.enabled.is_(True))
.order_by(Model.position, Model.model_id)
)
candidates = list(db.scalars(query))
if user is not None and user.is_admin:
return candidates
if user is None:
return []
member_of = {group.id for group in user.groups}
return [
model
for model in candidates
if model.public or member_of.intersection({g.id for g in model.groups})
]
def can_use_model(db: DBSession, user: User | None, model_id: str) -> bool:
return any(model.model_id == model_id for model in models_visible_to(db, user))
+34 -19
View File
@@ -110,28 +110,43 @@ def build_request(db: DBSession, chat: Chat, *, upto: Message | None = None) ->
}
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:
def default_model(db: DBSession, user=None) -> tuple[str, str] | None:
"""The model a new chat should start with, as (model_id, connection_id).
Preference order: the user's own choice, then the instance default, then
whatever is first in the admin's ordering. Each is checked against what the
user may actually reach, so a default they have lost access to falls
through rather than producing a chat they cannot use.
"""
from lembas.security import permissions
from lembas.services import settings_store
reachable = permissions.models_visible_to(db, user)
if not reachable:
return None
return model.model_id, model.connection_id
by_id = {model.model_id: model for model in reachable}
preferred = (user.settings_json or {}).get("default_model") if user is not None else None
if preferred and preferred in by_id:
return preferred, by_id[preferred].connection_id
instance_default = settings_store.get(db, "default_model")
if instance_default and instance_default in by_id:
return instance_default, by_id[instance_default].connection_id
# Pinned models sort first, matching what the picker shows at the top.
ordered = sorted(reachable, key=lambda m: (not m.pinned, m.position, m.model_id))
chosen = ordered[0]
return chosen.model_id, chosen.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 available_models(db: DBSession, user=None) -> list[Model]:
"""Models this user may start a chat with, pinned first."""
from lembas.security import permissions
reachable = permissions.models_visible_to(db, user)
return sorted(reachable, key=lambda m: (not m.pinned, m.position, m.model_id))
def fallback_title(text: str) -> str:
+22
View File
@@ -223,6 +223,28 @@ async def complete(endpoint: Endpoint, payload: dict[str, Any]) -> str:
raise LLMError("The endpoint returned no completion.") from exc
def delta_reasoning(chunk: dict[str, Any]) -> str:
"""Pull a reasoning delta out of one streamed chunk.
Providers disagree on the field name -- llama.cpp, llama-swap and vLLM use
``reasoning_content``, some others just ``reasoning`` -- so both are read.
Models that emit ``<think>`` tags inline in ``content`` instead are handled
by lembas.services.reasoning.
"""
try:
choices = chunk.get("choices") or []
if not choices:
return ""
delta = choices[0].get("delta") or {}
for field in ("reasoning_content", "reasoning"):
value = delta.get(field)
if isinstance(value, str) and value:
return value
return ""
except (AttributeError, TypeError):
return ""
def delta_text(chunk: dict[str, Any]) -> str:
"""Pull the text out of one streamed chunk, tolerating provider variation."""
try:
+133
View File
@@ -0,0 +1,133 @@
"""Separating a reasoning model's thinking from its answer.
Endpoints do this two different ways and LLeMbas has to cope with both:
1. A dedicated ``reasoning_content`` field in the streamed delta. This is what
llama.cpp, llama-swap, vLLM and DeepSeek emit, and it is unambiguous.
2. ``<think>...</think>`` tags inline in ``content``. Ollama and various
proxies do this, and it is a nuisance: the tags arrive split across chunks,
so the text has to be scanned as a stream rather than with a regex at the
end.
The splitter below handles the second case. It buffers only as much as a
partial tag could occupy, so latency is unaffected in the overwhelmingly common
case where no tag is present at all.
"""
from __future__ import annotations
from collections.abc import Iterator
# Tag spellings seen in the wild. Checked longest-first so <thinking> is not
# mistaken for <think> followed by "ing>".
_TAGS: tuple[tuple[str, str], ...] = (
("<thinking>", "</thinking>"),
("<think>", "</think>"),
("<reasoning>", "</reasoning>"),
)
REASONING = "reasoning"
CONTENT = "content"
# Longest opening tag, minus one: the most that can ever need holding back
# while waiting to see whether a partial "<thi" turns into a real tag.
_MAX_PARTIAL = max(len(open_tag) for open_tag, _ in _TAGS) - 1
class ReasoningSplitter:
"""Splits a stream of content chunks into reasoning and answer runs.
Feed it whatever arrives; it yields ``(kind, text)`` pairs. Call
:meth:`flush` at the end to release anything still buffered.
"""
def __init__(self) -> None:
self._buffer = ""
self._in_reasoning = False
self._closing = ""
def feed(self, chunk: str) -> Iterator[tuple[str, str]]:
self._buffer += chunk
yield from self._drain(final=False)
def flush(self) -> Iterator[tuple[str, str]]:
yield from self._drain(final=True)
def _drain(self, *, final: bool) -> Iterator[tuple[str, str]]:
while self._buffer:
if self._in_reasoning:
index = self._buffer.find(self._closing)
if index == -1:
# Hold back enough that a closing tag split across chunks is
# still recognised once the rest arrives.
keep = 0 if final else len(self._closing) - 1
emit, self._buffer = self._split(keep)
if emit:
yield (REASONING, emit)
return
if index:
yield (REASONING, self._buffer[:index])
self._buffer = self._buffer[index + len(self._closing) :]
self._in_reasoning = False
self._closing = ""
continue
opening_at, opening, closing = self._find_opening()
if opening_at == -1:
keep = 0 if final else _MAX_PARTIAL
emit, self._buffer = self._split(keep)
if emit:
yield (CONTENT, emit)
return
if opening_at:
yield (CONTENT, self._buffer[:opening_at])
self._buffer = self._buffer[opening_at + len(opening) :]
self._in_reasoning = True
self._closing = closing
def _find_opening(self) -> tuple[int, str, str]:
best = (-1, "", "")
for opening, closing in _TAGS:
index = self._buffer.find(opening)
if index != -1 and (best[0] == -1 or index < best[0]):
best = (index, opening, closing)
return best
def _split(self, keep: int) -> tuple[str, str]:
"""Emit everything except the last `keep` characters."""
if keep <= 0:
return self._buffer, ""
if len(self._buffer) <= keep:
return "", self._buffer
return self._buffer[:-keep], self._buffer[-keep:]
def strip_reasoning(text: str) -> tuple[str, str]:
"""Split a complete string into (answer, reasoning).
The non-streaming counterpart, used when replaying stored content.
"""
splitter = ReasoningSplitter()
answer: list[str] = []
thinking: list[str] = []
for kind, piece in splitter.feed(text):
(thinking if kind == REASONING else answer).append(piece)
for kind, piece in splitter.flush():
(thinking if kind == REASONING else answer).append(piece)
return "".join(answer), "".join(thinking)
def format_duration(milliseconds: int) -> str:
"""Human phrasing for the 'Thought for ...' label."""
if milliseconds <= 0:
return ""
seconds = milliseconds / 1000
if seconds < 1:
return "less than a second"
if seconds < 60:
return f"{seconds:.0f} second{'' if round(seconds) == 1 else 's'}"
minutes, remainder = divmod(int(seconds), 60)
if remainder == 0:
return f"{minutes} minute{'' if minutes == 1 else 's'}"
return f"{minutes}m {remainder}s"
+106
View File
@@ -0,0 +1,106 @@
"""Storing uploaded images.
Only model avatars use this today. Files are written under the data directory
and served back by a dedicated route, never from a URL supplied by a user --
a remote image URL would turn every page render into a request to a third
party, which is both a privacy leak and a way to make the UI depend on someone
else's uptime.
"""
from __future__ import annotations
import logging
import secrets
from pathlib import Path
from lembas.config import settings
log = logging.getLogger(__name__)
# Raster and vector formats a browser will render inline. Deliberately narrow:
# every entry here is something that cannot execute in an <img> tag.
ALLOWED_TYPES: dict[str, str] = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
}
MAX_BYTES = 2 * 1024 * 1024 # 2 MB; these are 64px avatars
# Magic numbers, checked against the declared content type. A browser sniffs
# content, so trusting the client's Content-Type alone would let a file claim
# to be a PNG and be served as something else.
_SIGNATURES: tuple[tuple[bytes, str], ...] = (
(b"\x89PNG\r\n\x1a\n", "image/png"),
(b"\xff\xd8\xff", "image/jpeg"),
(b"GIF87a", "image/gif"),
(b"GIF89a", "image/gif"),
)
class UploadError(Exception):
"""A rejected upload, with a message fit to show the user."""
def _detect(payload: bytes) -> str | None:
for signature, media_type in _SIGNATURES:
if payload.startswith(signature):
return media_type
# WEBP is "RIFF" + 4 size bytes + "WEBP".
if payload[:4] == b"RIFF" and payload[8:12] == b"WEBP":
return "image/webp"
return None
def models_dir() -> Path:
path = settings.uploads_dir / "models"
path.mkdir(parents=True, exist_ok=True)
return path
def save_model_image(payload: bytes, declared_type: str) -> str:
"""Validate and store a model avatar. Returns the stored filename."""
if not payload:
raise UploadError("The file was empty.")
if len(payload) > MAX_BYTES:
raise UploadError(f"Images must be under {MAX_BYTES // (1024 * 1024)} MB.")
actual = _detect(payload)
if actual is None:
raise UploadError("That does not look like a PNG, JPEG, WEBP or GIF image.")
if declared_type and declared_type.split(";")[0].strip() != actual:
# Not fatal on its own, but worth knowing about.
log.info("upload declared %s but is actually %s", declared_type, actual)
# Random name rather than the client's: no path traversal, no collisions,
# and no leaking whatever the uploader called the file.
filename = f"{secrets.token_hex(16)}{ALLOWED_TYPES[actual]}"
(models_dir() / filename).write_bytes(payload)
return filename
def model_image_path(filename: str) -> Path | None:
"""Resolve a stored filename to a path, refusing anything outside the dir."""
if not filename or "/" in filename or "\\" in filename or filename.startswith("."):
return None
path = (models_dir() / filename).resolve()
try:
path.relative_to(models_dir().resolve())
except ValueError:
return None
return path if path.is_file() else None
def delete_model_image(filename: str) -> None:
path = model_image_path(filename)
if path is not None:
path.unlink(missing_ok=True)
def media_type_for(filename: str) -> str:
suffix = Path(filename).suffix.lower()
for media_type, extension in ALLOWED_TYPES.items():
if extension == suffix:
return media_type
return "application/octet-stream"
+75
View File
@@ -103,6 +103,81 @@
}
.nav-item.is-disabled:hover { background: none; color: var(--ink-muted); }
/* --- Model list ----------------------------------------------------------- */
.bulk-bar {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
margin-bottom: var(--sp-4);
}
.bulk-bar .model-rows { flex-basis: 100%; margin-top: var(--sp-3); }
.model-rows {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.model-row {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.model-row:last-child { border-bottom: 0; }
.model-row.is-off { opacity: 0.55; }
.model-row__check { accent-color: var(--accent); width: 1rem; height: 1rem; flex: none; }
.model-row__avatar { flex: none; }
.model-row__main { flex: 1; min-width: 0; }
.model-row__title {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.model-row__id {
display: block;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-row__actions { display: flex; gap: var(--sp-1); flex: none; align-items: center; }
/* --- Permission and checkbox grids ---------------------------------------- */
.checkbox-row {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-4);
}
.checkbox-row .checkbox { flex: 0 0 auto; }
.checkbox.is-muted { opacity: 0.6; }
.perm-row {
align-items: flex-start;
padding: var(--sp-2) 0;
border-bottom: 1px solid var(--border);
}
.perm-row:last-child { border-bottom: 0; }
.perm-row input { margin-top: 0.2rem; }
.perm-row__desc {
display: block;
font-size: var(--text-xs);
color: var(--ink-faint);
font-weight: 400;
line-height: var(--leading-normal);
}
.input--file {
padding: 0.35rem;
font-size: var(--text-sm);
background: var(--surface-raised);
}
.field__hint code {
font-family: var(--font-mono);
font-size: 0.92em;
+111
View File
@@ -137,6 +137,66 @@
}
@keyframes caret { 0%, 49% { opacity: 0.75; } 50%, 100% { opacity: 0; } }
/* --- Reasoning ------------------------------------------------------------ */
.reasoning {
margin: 0 0 var(--sp-3);
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in srgb, var(--surface) 70%, transparent);
font-size: var(--text-sm);
}
/*
A live block is emitted before the first token arrives, and plenty of models
emit no reasoning at all. Hiding it until it has content means those models
never show an empty "Thinking" box, and no JavaScript is involved either way.
*/
.reasoning--live:not(:has(.reasoning__body:not(:empty))) { display: none; }
.reasoning__summary {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
cursor: pointer;
color: var(--ink-muted);
list-style: none;
user-select: none;
border-radius: var(--radius);
}
.reasoning__summary::-webkit-details-marker { display: none; }
.reasoning__summary:hover { color: var(--ink); background: var(--surface-hover); }
.reasoning__icon { color: var(--gold); flex: none; }
.reasoning__label { flex: 1; font-style: italic; }
.reasoning__chevron {
flex: none;
transition: transform var(--transition-fast);
}
.reasoning[open] .reasoning__chevron { transform: rotate(180deg); }
.reasoning__body {
padding: 0 var(--sp-3) var(--sp-3);
margin-left: var(--sp-2);
border-left: 2px solid var(--border-strong);
padding-left: var(--sp-3);
white-space: pre-wrap;
color: var(--ink-muted);
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
max-height: 26rem;
overflow-y: auto;
scrollbar-width: thin;
}
/* Gentle pulse on the icon while thinking is still streaming. */
.reasoning--live .reasoning__icon { animation: think-pulse 1.6s ease-in-out infinite; }
@keyframes think-pulse {
0%, 100% { opacity: 0.45; }
50% { opacity: 1; }
}
/* --- Message actions ------------------------------------------------------ */
.msg__actions {
display: flex;
@@ -326,6 +386,57 @@
text-decoration: none;
}
/* --- Chat settings panel -------------------------------------------------- */
.chat-settings {
flex: none;
border-bottom: 1px solid var(--border);
background: var(--bg-sunken);
max-height: 60vh;
overflow-y: auto;
}
.chat-settings__inner {
max-width: var(--thread-max-width);
margin: 0 auto;
padding: var(--sp-4) var(--sp-5);
}
.chat-settings__note {
color: var(--ink-muted);
font-size: var(--text-sm);
font-style: italic;
margin-bottom: var(--sp-4);
}
.chat-settings__params {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: var(--sp-3);
}
.chat-settings__params .field { margin-bottom: 0; }
/* --- Model avatars -------------------------------------------------------- */
.model-avatar {
width: 2rem;
height: 2rem;
flex: none;
border-radius: var(--radius);
object-fit: cover;
background: var(--surface-active);
}
.model-avatar--initial {
display: grid;
place-items: center;
font-family: var(--font-display);
font-weight: 600;
font-size: var(--text-sm);
/* Hue comes from the model id (see the stable_hue filter); saturation and
lightness are fixed so every generated badge stays legible in both themes. */
background: hsl(var(--avatar-hue, 40) 42% 34%);
color: hsl(var(--avatar-hue, 40) 60% 92%);
}
:root[data-theme="shire"] .model-avatar--initial {
background: hsl(var(--avatar-hue, 40) 46% 82%);
color: hsl(var(--avatar-hue, 40) 60% 22%);
}
/* --- Theme toggle --------------------------------------------------------- */
/* Only the icon for the theme you would switch TO is shown. */
:root[data-theme="moria"] .theme-icon--dark { display: none; }
+19
View File
@@ -60,6 +60,25 @@
<span class="brand-llm">LL</span>e<span class="brand-llm">M</span>bas
{%- endmacro %}
{#
A model's avatar: the uploaded image, or a generated initial.
The fallback colour is derived from the model id, so every model gets a
stable, distinct-looking badge without an administrator having to upload
anything. Hue only -- saturation and lightness are fixed so the result always
sits legibly against both themes.
#}
{% macro model_avatar(model, cls="model-avatar") -%}
{% if model.image_path %}
<img class="{{ cls }}" src="/uploads/models/{{ model.image_path }}"
alt="" loading="lazy" width="32" height="32">
{% else %}
<span class="{{ cls }} model-avatar--initial"
style="--avatar-hue: {{ model.model_id | stable_hue }}"
aria-hidden="true">{{ model.initial }}</span>
{% endif %}
{%- endmacro %}
{% macro brand(href="/", uid="a") -%}
<a class="sidebar__brand" href="{{ href }}">
{{ mark(uid=uid) }}
+12 -4
View File
@@ -35,17 +35,25 @@
{{ icon("sliders", "icon--sm") }}
<span class="nav-item__label">Models</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'users' }}" href="/admin/users">
{{ icon("user", "icon--sm") }}
<span class="nav-item__label">Users</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'groups' }}" href="/admin/groups">
{{ icon("users", "icon--sm") }}
<span class="nav-item__label">Groups &amp; permissions</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 &amp; groups</span>
{{ icon("gear", "icon--sm") }}
<span class="nav-item__label">Tools</span>
</span>
<span class="nav-item is-disabled">
{{ icon("sliders", "icon--sm") }}
<span class="nav-item__label">Tools</span>
{{ icon("server", "icon--sm") }}
<span class="nav-item__label">Agents</span>
</span>
</div>
</nav>
+167
View File
@@ -0,0 +1,167 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon, model_avatar %}
{% set section = "groups" %}
{% block title %}Groups &amp; permissions - LLeMbas{% endblock %}
{% block heading %}Groups &amp; permissions{% endblock %}
{% block admin_content %}
<p class="admin-lede">
Permissions are a <strong>union</strong>: everyone starts with the baseline
below, and each group they belong to can add more. A group never takes
something away, so being in a second group can only widen what someone can do.
Administrators bypass all of it.
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
<section class="card">
<h2 class="card__title">Baseline permissions</h2>
<p class="text-sm muted" style="margin-bottom: var(--sp-4)">
What every signed-in user can do before any group is considered. Turn
something off here and grant it through a group to make it opt-in.
</p>
<form method="post" action="/admin/permissions/defaults">
{% for section_name, defs in permission_groups.items() %}
<div class="field">
<span class="field__label">{{ section_name }}</span>
{% for definition in defs %}
<label class="checkbox perm-row">
<input type="checkbox" name="permission" value="{{ definition.key }}"
{{ 'checked' if baseline[definition.key] }}>
<span>
<strong>{{ definition.label }}</strong>
<span class="perm-row__desc">{{ definition.description }}</span>
</span>
</label>
{% endfor %}
</div>
{% endfor %}
<button class="btn btn--primary" type="submit">Save baseline</button>
</form>
</section>
<h2 class="admin-section-title">
Groups <span class="badge">{{ groups|length }}</span>
</h2>
<section class="card">
<form method="post" action="/admin/groups" class="row" style="gap: var(--sp-2)">
<input class="input" name="name" placeholder="New group name" required
aria-label="New group name">
<button class="btn btn--primary" type="submit">
{{ icon("plus", "icon--sm") }} Create group
</button>
</form>
</section>
{% if not groups %}
<div class="empty" style="padding: var(--sp-8) 0">
{{ icon("users", "empty__mark") }}
<p class="empty__text">
No groups yet. Create one to grant extra permissions, or to restrict a model
to a subset of users.
</p>
</div>
{% endif %}
{% for group in groups %}
<section class="card">
<form method="post" action="/admin/groups/{{ group.id }}">
<div class="row row--between" style="margin-bottom: var(--sp-4)">
<strong>{{ group.name }}</strong>
<span class="text-xs faint">
{{ group.users|length }} member{{ '' if group.users|length == 1 else 's' }},
{{ group.models|length }} model{{ '' if group.models|length == 1 else 's' }}
</span>
</div>
<div class="field">
<label class="field__label" for="gn-{{ group.id }}">Name</label>
<input class="input" id="gn-{{ group.id }}" name="name" value="{{ group.name }}" required>
</div>
<div class="field">
<label class="field__label" for="gd-{{ group.id }}">Description</label>
<input class="input" id="gd-{{ group.id }}" name="description"
value="{{ group.description }}" placeholder="What is this group for?">
</div>
<div class="field">
<span class="field__label">Grants</span>
<p class="field__hint" style="margin-bottom: var(--sp-2)">
Anything already in the baseline stays on regardless — these only add.
</p>
{% for section_name, defs in permission_groups.items() %}
{% for definition in defs %}
<label class="checkbox perm-row">
<input type="checkbox" name="permission" value="{{ definition.key }}"
{{ 'checked' if (group.permissions_json or {}).get(definition.key) }}>
<span>
<strong>{{ definition.label }}</strong>
<span class="perm-row__desc">
{{ definition.description }}
{% if baseline[definition.key] %}<em>(already in the baseline)</em>{% endif %}
</span>
</span>
</label>
{% endfor %}
{% endfor %}
</div>
<div class="field">
<span class="field__label">Members</span>
{% if users %}
<div class="checkbox-row">
{% for account in users %}
<label class="checkbox">
<input type="checkbox" name="user_ids" value="{{ account.id }}"
{{ 'checked' if account in group.users }}>
<span>{{ account.name }}</span>
</label>
{% endfor %}
</div>
{% else %}
<p class="field__hint">No users yet.</p>
{% endif %}
</div>
<div class="field">
<span class="field__label">Model access</span>
<p class="field__hint" style="margin-bottom: var(--sp-2)">
Models marked “available to everyone” are reachable regardless. These
grant access to the restricted ones.
</p>
{% if models %}
<div class="checkbox-row">
{% for model in models %}
<label class="checkbox {{ 'is-muted' if model.public }}">
<input type="checkbox" name="model_ids" value="{{ model.id }}"
{{ 'checked' if model in group.models }}>
<span>{{ model.label }}{% if model.public %} <em>(public)</em>{% endif %}</span>
</label>
{% endfor %}
</div>
{% else %}
<p class="field__hint">No models yet.</p>
{% endif %}
</div>
<button class="btn btn--primary" type="submit">Save {{ group.name }}</button>
</form>
<div class="connection__footer">
<span class="text-xs faint">Deleting a group leaves its members alone.</span>
<form method="post" action="/admin/groups/{{ group.id }}/delete"
onsubmit="return confirm('Delete the group “{{ group.name }}”?')">
<button class="btn btn--sm btn--danger" type="submit">
{{ icon("trash", "icon--sm") }} Delete
</button>
</form>
</div>
</section>
{% endfor %}
{% endblock %}
+174 -33
View File
@@ -1,5 +1,5 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% from "_macros.html" import icon, model_avatar %}
{% set section = "models" %}
{% block title %}Models - LLeMbas{% endblock %}
@@ -7,45 +7,186 @@
{% 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 &amp; refresh” will not bring a disabled model back on.
Every model discovered across your connections. The order here is the order
users see. Pinned models are offered first, and the default is what a new chat
starts with.
</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 saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
{% if not connection.models %}
<p class="muted text-sm">
No models loaded. Run “Test &amp; 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 %}
{% if not models %}
<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.
No models yet. <a href="/admin/connections">Add a connection</a> and run
“Test &amp; refresh”.
</p>
</div>
{% else %}
<form method="post" action="/admin/models/bulk" class="bulk-bar">
<span class="text-sm muted">With selected:</span>
<button class="btn btn--sm" name="action" value="enable" type="submit">Enable</button>
<button class="btn btn--sm" name="action" value="disable" type="submit">Disable</button>
<button class="btn btn--sm" name="action" value="public" type="submit">Make public</button>
<button class="btn btn--sm" name="action" value="private" type="submit">Restrict</button>
<div class="model-rows">
{% for model in models %}
<div class="model-row {{ 'is-off' if not model.enabled }}">
<input class="model-row__check" type="checkbox" name="model_ids" value="{{ model.id }}"
aria-label="Select {{ model.label }}">
{{ model_avatar(model, cls="model-row__avatar") }}
<div class="model-row__main">
<div class="model-row__title">
<strong>{{ model.label }}</strong>
{% if model.model_id == default_model %}
<span class="badge badge--gold">{{ icon("star", "icon--sm") }} default</span>
{% endif %}
{% if model.pinned %}<span class="badge">pinned</span>{% endif %}
{% if not model.enabled %}<span class="badge badge--danger">disabled</span>{% endif %}
{% if not model.public %}
<span class="badge">{{ model.groups|length }} group{{ '' if model.groups|length == 1 else 's' }}</span>
{% endif %}
{% for name, on in (model.capabilities_json or {}).items() %}
{% if on %}<span class="badge badge--gold">{{ name }}</span>{% endif %}
{% endfor %}
</div>
<code class="model-row__id">{{ model.model_id }}</code>
<span class="text-xs faint">via {{ model.connection.name }}</span>
</div>
<div class="model-row__actions">
<button class="btn btn--icon btn--sm" type="submit" aria-label="Move up"
formaction="/admin/models/{{ model.id }}/move" formmethod="post"
name="direction" value="up" {{ 'disabled' if loop.first }}>
{{ icon("arrow-up", "icon--sm") }}
</button>
<button class="btn btn--icon btn--sm" type="submit" aria-label="Move down"
formaction="/admin/models/{{ model.id }}/move" formmethod="post"
name="direction" value="down" {{ 'disabled' if loop.last }}>
{{ icon("arrow-down", "icon--sm") }}
</button>
<a class="btn btn--sm" href="#model-{{ model.id }}">Edit</a>
</div>
</div>
{% endfor %}
</div>
</form>
<h2 class="admin-section-title">Model settings</h2>
{% for model in models %}
<section class="card" id="model-{{ model.id }}">
<div class="row row--between" style="margin-bottom: var(--sp-4)">
<div class="row" style="gap: var(--sp-3); min-width: 0">
{{ model_avatar(model, cls="model-row__avatar") }}
<div style="min-width: 0">
<strong class="truncate">{{ model.label }}</strong>
<div><code class="text-xs faint">{{ model.model_id }}</code></div>
</div>
</div>
{% if model.model_id != default_model %}
<form method="post" action="/admin/models/{{ model.id }}/default">
<button class="btn btn--sm" type="submit">{{ icon("star", "icon--sm") }} Make default</button>
</form>
{% endif %}
</div>
<form method="post" action="/admin/models/{{ model.id }}">
<div class="field">
<label class="field__label" for="dn-{{ model.id }}">Display name</label>
<input class="input" id="dn-{{ model.id }}" name="display_name"
value="{{ model.display_name }}" placeholder="{{ model.model_id }}">
<p class="field__hint">Shown instead of the raw model id. Leave empty to use the id.</p>
</div>
<div class="field">
<label class="field__label" for="desc-{{ model.id }}">Description</label>
<textarea class="textarea" id="desc-{{ model.id }}" name="description" rows="2"
placeholder="What is this model good at?">{{ model.description }}</textarea>
</div>
<div class="field">
<span class="field__label">Capabilities</span>
<div class="checkbox-row">
{% for name in capabilities %}
<label class="checkbox">
<input type="checkbox" name="capability" value="{{ name }}"
{{ 'checked' if (model.capabilities_json or {}).get(name) }}>
<span>{{ name }}</span>
</label>
{% endfor %}
</div>
<p class="field__hint">
Endpoints rarely advertise these reliably, so they are your call.
<strong>reasoning</strong> shows the thinking block;
<strong>vision</strong> and <strong>tools</strong> are used by features
not built yet.
</p>
</div>
<div class="field">
<div class="checkbox-row">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if model.enabled }}>
<span>Enabled</span>
</label>
<label class="checkbox">
<input type="checkbox" name="pinned" value="true" {{ 'checked' if model.pinned }}>
<span>Pinned — offered first in the picker</span>
</label>
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="public" value="true" {{ 'checked' if model.public }}>
<span>Available to everyone</span>
</label>
<p class="field__hint">
Uncheck to restrict this model to specific groups. Administrators always
have access.
</p>
{% if groups %}
<div class="checkbox-row" style="margin-top: var(--sp-3)">
{% for group in groups %}
<label class="checkbox">
<input type="checkbox" name="group_ids" value="{{ group.id }}"
{{ 'checked' if group in model.groups }}>
<span>{{ group.name }}</span>
</label>
{% endfor %}
</div>
{% else %}
<p class="field__hint">
No groups yet — <a href="/admin/groups">create one</a> to restrict access.
</p>
{% endif %}
</div>
<button class="btn btn--primary" type="submit">Save {{ model.label }}</button>
</form>
<div class="connection__footer">
<form method="post" action="/admin/models/{{ model.id }}/image"
enctype="multipart/form-data" class="row" style="gap: var(--sp-2)">
<input class="input input--file" type="file" name="image"
accept="image/png,image/jpeg,image/webp,image/gif" required
aria-label="Model image">
<button class="btn btn--sm" type="submit">{{ icon("image", "icon--sm") }} Upload</button>
</form>
{% if model.image_path %}
<form method="post" action="/admin/models/{{ model.id }}/image/delete">
<button class="btn btn--sm btn--danger" type="submit">Remove image</button>
</form>
{% endif %}
</div>
</section>
{% endfor %}
{% endif %}
{% endblock %}
+161
View File
@@ -0,0 +1,161 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "users" %}
{% block title %}Users - LLeMbas{% endblock %}
{% block heading %}Users{% endblock %}
{% block admin_content %}
<p class="admin-lede">
Everyone with an account on this instance. Administrators bypass every
permission; ordinary users get the baseline permissions plus whatever their
groups add.
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
<form method="get" action="/admin/users" class="row" style="margin-bottom: var(--sp-5)">
<input class="input" type="search" name="q" value="{{ q }}"
placeholder="Search by name or email" aria-label="Search users">
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Search</button>
{% if q %}<a class="btn btn--ghost" href="/admin/users">Clear</a>{% endif %}
</form>
<details class="card">
<summary class="card__title" style="cursor: pointer">Add a user</summary>
<form method="post" action="/admin/users" style="margin-top: var(--sp-4)">
<div class="field">
<label class="field__label" for="nu-name">Name</label>
<input class="input" id="nu-name" name="name" required>
</div>
<div class="field">
<label class="field__label" for="nu-email">Email</label>
<input class="input" id="nu-email" name="email" type="email" required>
</div>
<div class="field">
<label class="field__label" for="nu-password">Password</label>
<input class="input" id="nu-password" name="password" type="password"
required minlength="8" autocomplete="new-password">
<p class="field__hint">
At least 8 characters. Tell them to change it — you will know it otherwise.
</p>
</div>
<div class="field">
<label class="field__label" for="nu-role">Role</label>
<select class="select" id="nu-role" name="role">
{% for role in roles %}
<option value="{{ role }}" {{ 'selected' if role == 'user' }}>{{ role }}</option>
{% endfor %}
</select>
</div>
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button>
</form>
</details>
<h2 class="admin-section-title">
Accounts <span class="badge">{{ users|length }}</span>
</h2>
{% for account in users %}
<section class="card">
<form method="post" action="/admin/users/{{ account.id }}">
<div class="row row--between" style="margin-bottom: var(--sp-4); flex-wrap: wrap">
<div class="row" style="gap: var(--sp-2); min-width: 0">
<span class="status-dot {{ 'is-ok' if account.active else 'is-off' }}"></span>
<strong class="truncate">{{ account.name }}</strong>
<code class="text-xs faint">{{ account.email }}</code>
{% if account.is_admin %}<span class="badge badge--gold">admin</span>{% endif %}
{% if not account.active %}<span class="badge badge--danger">deactivated</span>{% endif %}
{% if account.id == user.id %}<span class="badge">you</span>{% endif %}
</div>
<span class="text-xs faint">
{% if account.last_login_at %}
last seen {{ account.last_login_at.strftime("%Y-%m-%d %H:%M") }}
{% else %}
never signed in
{% endif %}
</span>
</div>
<div class="field">
<label class="field__label" for="un-{{ account.id }}">Name</label>
<input class="input" id="un-{{ account.id }}" name="name" value="{{ account.name }}" required>
</div>
<div class="field">
<label class="field__label" for="ur-{{ account.id }}">Role</label>
<select class="select" id="ur-{{ account.id }}" name="role">
{% for role in roles %}
<option value="{{ role }}" {{ 'selected' if role == account.role }}>{{ role }}</option>
{% endfor %}
</select>
<p class="field__hint">
<strong>admin</strong> can do everything, including this page.
<strong>user</strong> is an ordinary account.
<strong>pending</strong> cannot sign in until promoted.
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="active" value="true" {{ 'checked' if account.active }}>
<span>Active — may sign in</span>
</label>
<p class="field__hint">
Deactivating signs them out everywhere immediately, rather than waiting
for their session to expire.
</p>
</div>
{% if groups %}
<div class="field">
<span class="field__label">Groups</span>
<div class="checkbox-row">
{% for group in groups %}
<label class="checkbox">
<input type="checkbox" name="group_ids" value="{{ group.id }}"
{{ 'checked' if group in account.groups }}>
<span>{{ group.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if account.is_admin and admin_count <= 1 %}
<div class="alert alert--warning">
{{ icon("warning", "alert__icon") }}
<span>
The only administrator. Promote someone else before demoting or
deactivating this account — an instance with no admin can only be
recovered with <code>lembas create-admin</code>.
</span>
</div>
{% endif %}
<button class="btn btn--primary" type="submit">Save</button>
</form>
<div class="connection__footer">
<form method="post" action="/admin/users/{{ account.id }}/password" class="row"
style="gap: var(--sp-2)">
<input class="input" type="password" name="password" minlength="8"
placeholder="Set a new password" autocomplete="new-password" required
aria-label="New password for {{ account.email }}">
<button class="btn btn--sm" type="submit">{{ icon("key", "icon--sm") }} Reset</button>
</form>
{% if account.id != user.id %}
<form method="post" action="/admin/users/{{ account.id }}/delete"
onsubmit="return confirm('Delete {{ account.email }} and all their chats? This cannot be undone.')">
<button class="btn btn--sm btn--danger" type="submit">
{{ icon("trash", "icon--sm") }} Delete
</button>
</form>
{% endif %}
</div>
</section>
{% endfor %}
{% endblock %}
@@ -39,6 +39,19 @@
</header>
{% if streaming %}
{# Reasoning arrives before the answer, so this block sits above it. It
starts open (watching a model think is the point) and the :has() rule
in chat.css hides the whole thing while it is still empty, so models
that emit no reasoning never show an empty box. #}
<details class="reasoning reasoning--live" id="reasoning-{{ message.id }}" open>
<summary class="reasoning__summary">
{{ icon("sparkle", "icon--sm reasoning__icon") }}
<span class="reasoning__label">Thinking</span>
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
</summary>
<div class="reasoning__body" sse-swap="reasoning" hx-swap="beforeend"></div>
</details>
{# 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. #}
@@ -47,6 +60,25 @@
<div class="msg__waiting">
<span class="dots"><i></i><i></i><i></i></span>
</div>
{% elif message.reasoning and not message.error %}
{# Collapsed once finished: the answer is what the reader came for, and
the thinking is there if they want to audit it. #}
<details class="reasoning" id="reasoning-{{ message.id }}">
<summary class="reasoning__summary">
{{ icon("sparkle", "icon--sm reasoning__icon") }}
<span class="reasoning__label">
{% if message.reasoning_ms %}
Thought for {{ message.reasoning_ms | duration }}
{% else %}
Reasoning
{% endif %}
</span>
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
</summary>
<div class="reasoning__body">{{ message.reasoning }}</div>
</details>
<div class="msg__body">{{ body_html|safe }}</div>
{% elif message.error %}
<div class="alert alert--error msg__error" role="alert">
{{ icon("warning", "alert__icon") }}
+77 -6
View File
@@ -23,22 +23,93 @@
{% 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">
{% if models and can.get("chat.model_select") %}
<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>
{% if pinned_models %}
<optgroup label="Pinned">
{% for model in pinned_models %}
<option value="{{ model.model_id }}"
{{ 'selected' if model.model_id == chat.model_id }}>{{ model.label }}</option>
{% endfor %}
</optgroup>
<optgroup label="Other models">
{% endif %}
{% for model in other_models %}
<option value="{{ model.model_id }}"
{{ 'selected' if model.model_id == chat.model_id }}>{{ model.label }}</option>
{% endfor %}
{% if pinned_models %}</optgroup>{% endif %}
</select>
</form>
{% elif current_model %}
<span class="badge">{{ current_model.label }}</span>
{% endif %}
<button class="btn btn--icon" type="button" aria-label="Chat settings"
title="Chat settings"
onclick="document.getElementById('chat-settings').toggleAttribute('hidden')">
{{ icon("sliders") }}
</button>
{% else %}
<h1 class="topbar__title">Chats</h1>
{% endif %}
</header>
{% if chat and (can.get("chat.system_prompt") or can.get("chat.params")) %}
{# Collapsed by default: these are per-chat overrides, not everyday controls.
Each field saves on change rather than needing a Save button, so there is
no half-applied state to reason about. #}
<section class="chat-settings" id="chat-settings" hidden>
<div class="chat-settings__inner">
{% if current_model and current_model.description %}
<p class="chat-settings__note">{{ current_model.description }}</p>
{% endif %}
{% if can.get("chat.system_prompt") %}
<div class="field">
<label class="field__label" for="system-prompt">System prompt</label>
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="3"
placeholder="Instructions that apply to every message in this chat."
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
hx-trigger="change">{{ chat.system_prompt }}</textarea>
</div>
{% endif %}
{% if can.get("chat.params") %}
<div class="chat-settings__params">
<div class="field">
<label class="field__label" for="temperature">Temperature</label>
<input class="input" id="temperature" name="temperature" type="number"
min="0" max="2" step="0.05" placeholder="default"
value="{{ chat.params_json.get('temperature', '') }}"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
</div>
<div class="field">
<label class="field__label" for="top-p">Top-p</label>
<input class="input" id="top-p" name="top_p" type="number"
min="0" max="1" step="0.05" placeholder="default"
value="{{ chat.params_json.get('top_p', '') }}"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
</div>
<div class="field">
<label class="field__label" for="max-tokens">Max tokens</label>
<input class="input" id="max-tokens" name="max_tokens" type="number"
min="1" step="1" placeholder="default"
value="{{ chat.params_json.get('max_tokens', '') }}"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
</div>
</div>
<p class="field__hint">
Leave a field empty to let the provider decide. Values outside the
allowed range are ignored rather than clamped.
</p>
{% endif %}
</div>
</section>
{% endif %}
{% if not chat %}
{# No chat selected. #}
<div class="empty">
@@ -118,6 +118,24 @@
<path d="M12 4.2 21 19.5H3Z"/>
<path d="M12 10v4M12 16.8h.01"/>
</symbol>
<symbol id="i-sparkle" viewBox="0 0 24 24">
<path d="M12 3.5 13.9 9 19.5 11l-5.6 2-1.9 5.5L10.1 13 4.5 11l5.6-2Z"/>
<path d="M18.5 4v3M20 5.5h-3"/>
</symbol>
<symbol id="i-image" viewBox="0 0 24 24">
<rect x="3.5" y="5" width="17" height="14" rx="2"/>
<circle cx="9" cy="10" r="1.6"/>
<path d="m4.5 17 4.2-4.2a1.5 1.5 0 0 1 2.1 0l3 3 1.9-1.9a1.5 1.5 0 0 1 2.1 0l2 2"/>
</symbol>
<symbol id="i-arrow-up" viewBox="0 0 24 24"><path d="M12 19V6M6 12l6-6 6 6"/></symbol>
<symbol id="i-arrow-down" viewBox="0 0 24 24"><path d="M12 5v13M6 12l6 6 6-6"/></symbol>
<symbol id="i-star" viewBox="0 0 24 24">
<path d="m12 3.8 2.5 5.2 5.7.8-4.1 4 1 5.7-5.1-2.7-5.1 2.7 1-5.7-4.1-4 5.7-.8Z"/>
</symbol>
<symbol id="i-key" viewBox="0 0 24 24">
<circle cx="8" cy="12" r="4"/>
<path d="M12 12h8M17.5 12v3M20 12v2.5"/>
</symbol>
<symbol id="i-leaf" viewBox="0 0 64 64">
<path d="M20.5 45.5C13.8 31.7 23.8 20.9 45.5 18.5 49.8 35 39.8 45.8 20.5 45.5Z"/>
<path d="M20.5 45.5C28 38 36 29 45.5 18.5"/>
@@ -10,15 +10,24 @@
{{ brand(uid="side") }}
</div>
{# Buttons for actions the user cannot perform are omitted rather than
disabled: a greyed-out control invites a support question, an absent one
does not. The routes enforce the same permissions regardless. #}
{% if can.get("chat.create") or can.get("folder.manage") %}
<div class="sidebar__actions">
{% if can.get("chat.create") %}
<button class="btn btn--primary btn--block" hx-post="/api/chats" hx-swap="none">
{{ icon("plus", "icon--sm") }} New chat
</button>
{% endif %}
{% if can.get("folder.manage") %}
<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>
{% endif %}
</div>
{% endif %}
<nav class="sidebar__scroll" id="sidebar-tree" aria-label="Chats">
{% if folders %}
+46
View File
@@ -50,6 +50,52 @@
</div>
</section>
<section class="card">
<h2 class="card__title">Default model</h2>
{% if models %}
<form method="post" action="/api/preferences/default-model">
<div class="field">
<select class="select" name="model_id" aria-label="Default model">
<option value="">Use the instance default</option>
{% for model in models %}
<option value="{{ model.model_id }}"
{{ 'selected' if model.model_id == user.settings_json.get('default_model') }}>
{{ model.label }}{% if model.pinned %} — pinned{% endif %}
</option>
{% endfor %}
</select>
<p class="field__hint">What a new chat starts with. Existing chats keep their model.</p>
</div>
<button class="btn btn--primary" type="submit">Save</button>
</form>
{% else %}
<p class="muted text-sm">No models are available to you yet.</p>
{% endif %}
</section>
<section class="card">
<h2 class="card__title">Permissions</h2>
<p class="text-sm muted" style="margin-bottom: var(--sp-3)">
{% if user.is_admin %}
You are an administrator, so every permission applies.
{% else %}
What this account may do, from the instance baseline plus your groups.
{% endif %}
</p>
<div class="checkbox-row">
{% for key, granted in can.items() %}
<span class="badge {{ 'badge--success' if granted }}">
{{ key }}{{ '' if granted else ' — no' }}
</span>
{% endfor %}
</div>
{% if user.groups %}
<p class="field__hint" style="margin-top: var(--sp-3)">
Groups: {% for group in user.groups %}{{ group.name }}{% if not loop.last %}, {% endif %}{% endfor %}
</p>
{% endif %}
</section>
<section class="card">
<h2 class="card__title">Appearance</h2>
<div class="row" style="gap: var(--sp-3)">
+20
View File
@@ -11,6 +11,7 @@ from fastapi.templating import Jinja2Templates
from lembas import __version__
from lembas.config import settings
from lembas.db.models import User
from lembas.services.reasoning import format_duration
TEMPLATE_DIR = Path(__file__).parent / "templates"
STATIC_DIR = Path(__file__).parent / "static"
@@ -19,6 +20,25 @@ templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
templates.env.trim_blocks = True
templates.env.lstrip_blocks = True
# {{ message.reasoning_ms | duration }} -> "8 seconds"
templates.env.filters["duration"] = format_duration
def stable_hue(value: str) -> int:
"""A deterministic 0-359 hue for a string.
Used for generated model avatars so each model gets its own colour without
anyone choosing one, and the same model looks the same on every page and
after every restart. Python's hash() is salted per process, hence md5.
"""
import hashlib
digest = hashlib.md5(value.encode("utf-8"), usedforsecurity=False).digest()
return int.from_bytes(digest[:2], "big") % 360
templates.env.filters["stable_hue"] = stable_hue
def resolve_theme(user: User | None) -> str:
"""Theme to render with on the server.
+363
View File
@@ -0,0 +1,363 @@
"""Groups, permissions and model access control."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Chat, Connection, Group, Model, User
from lembas.security import permissions
from lembas.services import chat as chat_service
from lembas.services import settings_store
from lembas.services.crypto import encrypt
@pytest.fixture
def admin(client: TestClient, registered) -> None:
"""The registered fixture already makes an administrator."""
return None
@pytest.fixture
def plain_user(client: TestClient, db, registered) -> User:
"""A second, non-admin account. Leaves the client signed in as them."""
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,
)
return db.scalar(select(User).where(User.email == "sam@shire.test"))
def _connection(db) -> Connection:
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
return connection
def _model(db, model_id: str, **kwargs) -> Model:
model = Model(connection_id=_connection(db).id, model_id=model_id, **kwargs)
db.add(model)
db.commit()
return model
# --- Permission resolution ---------------------------------------------------
def test_admins_get_everything(db, registered):
admin_user = db.scalar(select(User).where(User.role == "admin"))
assert all(permissions.resolve(db, admin_user).values())
def test_signed_out_gets_nothing(db):
assert not any(permissions.resolve(db, None).values())
def test_plain_user_gets_the_baseline(db, plain_user):
resolved = permissions.resolve(db, plain_user)
assert resolved["chat.create"] is True
# Off in the baseline by default.
assert resolved["chat.params"] is False
def test_a_group_widens_permissions(db, plain_user):
group = Group(name="Power users", permissions_json={"chat.params": True})
group.users = [plain_user]
db.add(group)
db.commit()
assert permissions.resolve(db, plain_user)["chat.params"] is True
def test_groups_union_rather_than_override(db, plain_user):
"""A second group can only ever add. Absent means 'no opinion', not 'deny'."""
db.add_all(
[
Group(name="A", permissions_json={"chat.params": True}, users=[plain_user]),
Group(name="B", permissions_json={}, users=[plain_user]),
]
)
db.commit()
assert permissions.resolve(db, plain_user)["chat.params"] is True
def test_baseline_can_be_narrowed_instance_wide(db, plain_user):
settings_store.update(db, {"default_permissions": {"chat.create": False}})
assert permissions.resolve(db, plain_user)["chat.create"] is False
def test_a_group_can_grant_back_what_the_baseline_removed(db, plain_user):
settings_store.update(db, {"default_permissions": {"chat.create": False}})
db.add(Group(name="Writers", permissions_json={"chat.create": True}, users=[plain_user]))
db.commit()
assert permissions.resolve(db, plain_user)["chat.create"] is True
# --- Enforcement through the API ---------------------------------------------
def test_creating_a_chat_is_refused_without_permission(client: TestClient, db, plain_user):
settings_store.update(db, {"default_permissions": {"chat.create": False}})
assert client.post("/api/chats").status_code == 403
assert db.scalar(select(Chat)) is None
def test_folder_routes_are_refused_without_permission(client: TestClient, db, plain_user):
settings_store.update(db, {"default_permissions": {"folder.manage": False}})
assert client.post("/api/folders", data={"name": "Nope"}).status_code == 403
def test_changing_sampling_is_refused_without_permission(client: TestClient, db, plain_user):
_model(db, "test-model")
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
response = client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.9"})
assert response.status_code == 403
def test_sampling_is_allowed_once_a_group_grants_it(client: TestClient, db, plain_user):
_model(db, "test-model")
db.add(Group(name="Tuners", permissions_json={"chat.params": True}, users=[plain_user]))
db.commit()
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
assert client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.9"}).status_code == 204
chat = db.get(Chat, chat_id)
db.refresh(chat)
assert chat.params_json["temperature"] == 0.9
@pytest.mark.parametrize(
("field", "value"),
[("temperature", "5"), ("top_p", "-1"), ("max_tokens", "0"), ("temperature", "abc")],
)
def test_out_of_range_parameters_are_dropped_not_clamped(
client: TestClient, db, registered, field, value
):
"""Silently changing what someone typed is worse than ignoring it."""
_model(db, "test-model")
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
client.patch(f"/api/chats/{chat_id}", data={field: value})
chat = db.get(Chat, chat_id)
db.refresh(chat)
assert field not in (chat.params_json or {})
def test_an_empty_parameter_clears_it(client: TestClient, db, registered):
_model(db, "test-model")
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.7"})
client.patch(f"/api/chats/{chat_id}", data={"temperature": ""})
chat = db.get(Chat, chat_id)
db.refresh(chat)
assert chat.params_json["temperature"] is None
# --- Model access ------------------------------------------------------------
def test_public_models_are_visible_to_everyone(db, plain_user):
_model(db, "open-model", public=True)
assert [m.model_id for m in permissions.models_visible_to(db, plain_user)] == ["open-model"]
def test_restricted_models_are_hidden_without_a_group(db, plain_user):
_model(db, "secret-model", public=False)
assert permissions.models_visible_to(db, plain_user) == []
def test_a_group_grants_access_to_a_restricted_model(db, plain_user):
model = _model(db, "secret-model", public=False)
group = Group(name="Insiders", users=[plain_user], models=[model])
db.add(group)
db.commit()
assert [m.model_id for m in permissions.models_visible_to(db, plain_user)] == ["secret-model"]
def test_admins_see_restricted_models(db, registered):
_model(db, "secret-model", public=False)
admin_user = db.scalar(select(User).where(User.role == "admin"))
assert [m.model_id for m in permissions.models_visible_to(db, admin_user)] == ["secret-model"]
def test_disabled_models_are_hidden_from_everyone(db, registered):
_model(db, "off-model", enabled=False)
admin_user = db.scalar(select(User).where(User.role == "admin"))
assert permissions.models_visible_to(db, admin_user) == []
def test_switching_to_an_inaccessible_model_is_refused(client: TestClient, db, plain_user):
"""The picker is not the security boundary; a crafted request must fail."""
_model(db, "open-model", public=True)
_model(db, "secret-model", public=False)
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
response = client.patch(f"/api/chats/{chat_id}", data={"model_id": "secret-model"})
assert response.status_code == 403
chat = db.get(Chat, chat_id)
db.refresh(chat)
assert chat.model_id == "open-model"
def test_model_select_permission_is_required_to_switch(client: TestClient, db, plain_user):
_model(db, "a-model", public=True)
_model(db, "b-model", public=True)
settings_store.update(db, {"default_permissions": {"chat.model_select": False}})
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
assert client.patch(f"/api/chats/{chat_id}", data={"model_id": "b-model"}).status_code == 403
# --- Ordering and defaults ---------------------------------------------------
def test_pinned_models_sort_first(db, registered):
connection = _connection(db)
db.add_all(
[
Model(connection_id=connection.id, model_id="ordinary", position=0),
Model(connection_id=connection.id, model_id="favourite", position=9, pinned=True),
]
)
db.commit()
admin_user = db.scalar(select(User).where(User.role == "admin"))
assert [m.model_id for m in chat_service.available_models(db, admin_user)] == [
"favourite",
"ordinary",
]
def test_position_decides_order_among_unpinned(db, registered):
connection = _connection(db)
db.add_all(
[
Model(connection_id=connection.id, model_id="second", position=1),
Model(connection_id=connection.id, model_id="first", position=0),
]
)
db.commit()
admin_user = db.scalar(select(User).where(User.role == "admin"))
assert [m.model_id for m in chat_service.available_models(db, admin_user)] == [
"first",
"second",
]
def test_instance_default_model_is_used_for_new_chats(client: TestClient, db, registered):
connection = _connection(db)
db.add_all(
[
Model(connection_id=connection.id, model_id="first", position=0),
Model(connection_id=connection.id, model_id="chosen", position=5),
]
)
db.commit()
settings_store.update(db, {"default_model": "chosen"})
client.post("/api/chats")
assert db.scalar(select(Chat)).model_id == "chosen"
def test_a_users_own_default_beats_the_instance_default(client: TestClient, db, plain_user):
connection = _connection(db)
db.add_all(
[
Model(connection_id=connection.id, model_id="instance-pick", position=0),
Model(connection_id=connection.id, model_id="my-pick", position=5),
]
)
db.commit()
settings_store.update(db, {"default_model": "instance-pick"})
client.post("/api/preferences/default-model", data={"model_id": "my-pick"})
client.post("/api/chats")
assert db.scalar(select(Chat)).model_id == "my-pick"
def test_an_unreachable_default_falls_through(db, plain_user):
"""A default the user has lost access to must not produce a dead chat."""
connection = _connection(db)
db.add_all(
[
Model(connection_id=connection.id, model_id="allowed", position=0, public=True),
Model(connection_id=connection.id, model_id="gone", position=1, public=False),
]
)
db.commit()
settings_store.update(db, {"default_model": "gone"})
assert chat_service.default_model(db, plain_user)[0] == "allowed"
def test_choosing_an_inaccessible_default_is_refused(client: TestClient, db, plain_user):
_model(db, "secret-model", public=False)
response = client.post(
"/api/preferences/default-model", data={"model_id": "secret-model"}, follow_redirects=False
)
assert "error=" in response.headers["location"]
db.refresh(plain_user)
assert "default_model" not in (plain_user.settings_json or {})
# --- Admin guards ------------------------------------------------------------
def test_ordinary_users_cannot_reach_user_administration(client: TestClient, db, plain_user):
for path in ("/admin/users", "/admin/groups", "/admin/models"):
assert client.get(path, follow_redirects=False).status_code == 403, path
def test_the_last_administrator_cannot_be_demoted(client: TestClient, db, registered):
admin_user = db.scalar(select(User).where(User.role == "admin"))
response = client.post(
f"/admin/users/{admin_user.id}",
data={"name": admin_user.name, "role": "user", "active": "true"},
follow_redirects=False,
)
assert "only+administrator" in response.headers["location"]
db.refresh(admin_user)
assert admin_user.role == "admin"
def test_you_cannot_delete_your_own_account(client: TestClient, db, registered):
admin_user = db.scalar(select(User).where(User.role == "admin"))
response = client.post(
f"/admin/users/{admin_user.id}/delete", follow_redirects=False
)
assert "your+own+account" in response.headers["location"]
assert db.get(User, admin_user.id) is not None
def test_deactivating_a_user_revokes_their_sessions(client: TestClient, db, registered):
"""Otherwise the change only lands when their cookie happens to expire."""
other = TestClient(client.app)
other.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
assert other.get("/chat", follow_redirects=False).status_code == 200
target = db.scalar(select(User).where(User.email == "sam@shire.test"))
client.post(
f"/admin/users/{target.id}",
data={"name": target.name, "role": "user"}, # `active` absent means off
follow_redirects=False,
)
assert other.get("/chat", follow_redirects=False).status_code == 303
def test_a_pinned_model_appears_once_in_the_picker(client: TestClient, db, registered):
"""Two options with the same value, both selected, is not a picker."""
connection = _connection(db)
db.add_all(
[
Model(connection_id=connection.id, model_id="favourite", pinned=True, position=0),
Model(connection_id=connection.id, model_id="ordinary", position=1),
]
)
db.commit()
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
page = client.get(f"/chat/{chat_id}").text
assert page.count('<option value="favourite"') == 1
assert page.count('<option value="ordinary"') == 1
+140
View File
@@ -0,0 +1,140 @@
"""Splitting a reasoning model's thinking from its answer."""
from __future__ import annotations
import pytest
from lembas.services.llm.openai_client import delta_reasoning
from lembas.services.reasoning import (
CONTENT,
REASONING,
ReasoningSplitter,
format_duration,
strip_reasoning,
)
def run(chunks: list[str]) -> tuple[str, str]:
"""Feed chunks through the splitter and return (answer, reasoning)."""
splitter = ReasoningSplitter()
answer, thinking = [], []
for chunk in chunks:
for kind, piece in splitter.feed(chunk):
(thinking if kind == REASONING else answer).append(piece)
for kind, piece in splitter.flush():
(thinking if kind == REASONING else answer).append(piece)
return "".join(answer), "".join(thinking)
# --- The dedicated field -----------------------------------------------------
def test_reasoning_content_field():
chunk = {"choices": [{"delta": {"reasoning_content": "hmm"}}]}
assert delta_reasoning(chunk) == "hmm"
def test_plain_reasoning_field_is_also_accepted():
assert delta_reasoning({"choices": [{"delta": {"reasoning": "hmm"}}]}) == "hmm"
def test_no_reasoning_field():
assert delta_reasoning({"choices": [{"delta": {"content": "hi"}}]}) == ""
@pytest.mark.parametrize("chunk", [{}, {"choices": []}, {"choices": [{"delta": {}}]}])
def test_delta_reasoning_tolerates_junk(chunk):
assert delta_reasoning(chunk) == ""
# --- Inline <think> tags -----------------------------------------------------
def test_plain_content_passes_straight_through():
assert run(["Hello ", "world"]) == ("Hello world", "")
def test_think_block_is_extracted():
answer, thinking = run(["<think>weighing it up</think>The answer is 6."])
assert answer == "The answer is 6."
assert thinking == "weighing it up"
def test_tag_split_across_chunks():
"""The tag arrives in pieces, which is the whole reason this is a stream
machine and not a regex."""
answer, thinking = run(["<th", "ink>rea", "soning</thi", "nk>done"])
assert answer == "done"
assert thinking == "reasoning"
def test_single_character_chunks():
source = "<think>abc</think>xyz"
assert run(list(source)) == ("xyz", "abc")
def test_thinking_variant_is_not_mistaken_for_think():
answer, thinking = run(["<thinking>deep</thinking>shallow"])
assert answer == "shallow"
assert thinking == "deep"
def test_content_before_and_after_a_think_block():
answer, thinking = run(["before <think>mid</think> after"])
assert answer == "before after"
assert thinking == "mid"
def test_unterminated_think_block_flushes_as_reasoning():
"""A truncated stream must not lose the partial thinking."""
answer, thinking = run(["<think>never closed"])
assert answer == ""
assert thinking == "never closed"
def test_newlines_survive():
answer, thinking = run(["<think>a\nb</think>c\nd"])
assert thinking == "a\nb"
assert answer == "c\nd"
def test_a_lone_angle_bracket_is_not_swallowed():
assert run(["5 < 6 and 7 > 3"]) == ("5 < 6 and 7 > 3", "")
def test_no_output_is_withheld_at_the_end():
"""Whatever is buffered for a possible partial tag must be released on
flush, or the last few characters of every reply would vanish."""
answer, _ = run(["the end<thi"])
assert answer == "the end<thi"
def test_emits_incrementally_rather_than_only_at_the_end():
"""Buffering the whole reply would defeat the point of streaming."""
splitter = ReasoningSplitter()
emitted = list(splitter.feed("a fairly long stretch of ordinary answer text"))
assert emitted, "nothing emitted before flush"
assert emitted[0][0] == CONTENT
# --- Whole strings -----------------------------------------------------------
def test_strip_reasoning_round_trip():
answer, thinking = strip_reasoning("<think>because</think>Therefore 42.")
assert (answer, thinking) == ("Therefore 42.", "because")
def test_strip_reasoning_leaves_plain_text_alone():
assert strip_reasoning("just an answer") == ("just an answer", "")
# --- Duration phrasing -------------------------------------------------------
@pytest.mark.parametrize(
("milliseconds", "expected"),
[
(0, ""),
(-5, ""),
(400, "less than a second"),
(1000, "1 second"),
(8200, "8 seconds"),
(60000, "1 minute"),
(95000, "1m 35s"),
],
)
def test_format_duration(milliseconds, expected):
assert format_duration(milliseconds) == expected