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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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)
|
||||
+151
-28
@@ -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,14 +165,42 @@ 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))
|
||||
# Hand control back so the event is flushed rather than
|
||||
# batched behind a fast generator.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
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
|
||||
@@ -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
|
||||
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."
|
||||
)
|
||||
# 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) if m.model_id == model_id), None
|
||||
(m for m in chat_service.available_models(db, user) if m.model_id == model_id),
|
||||
None,
|
||||
)
|
||||
chat.connection_id = match.connection_id if match else 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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon, model_avatar %}
|
||||
{% set section = "groups" %}
|
||||
|
||||
{% block title %}Groups & permissions - LLeMbas{% endblock %}
|
||||
{% block heading %}Groups & 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 %}
|
||||
@@ -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 & 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 & 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 & 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 %}
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user