Fix bulk actions, create chats lazily, rework the UI

Seven reported problems.

**Bulk model actions 404'd.** /admin/models/{model_id} was registered
before /admin/models/bulk, and FastAPI matches in registration order, so
"bulk" was parsed as a model id. Moved above the parameterised route,
with a comment saying why, and a regression test.

**Empty chats piled up.** There is now no endpoint that creates one.
"New chat" is a link to /chat, which renders a composer with no row
behind it, and POST /api/chats/start writes the chat together with its
first message. Opening one and walking away leaves nothing.

**Pinning meant two different things.** The picker is now always in the
administrator's position order; pinned models get shortcuts in the chat
sidebar and nothing else. A picker whose order silently differs from the
admin screen is just confusing.

**Model images were missing in chat.** Assistant bubbles now show the
avatar of the model that actually wrote the turn -- which is not always
the model the chat is set to now -- falling back to the LLeMbas mark.
The picker shows it too.

**No global or per-model system prompt.** Three layers now: instance
(Admin -> General), model (Admin -> Models), chat. Precedence, not
concatenation: most specific wins outright. Stacking them reads well in
a settings screen and badly in practice, because two layers that
disagree give the model contradictory instructions and nobody can tell
which is losing. The chat panel shows the inherited prompt as
placeholder text so "leave empty to inherit" is not a guess.

**Alignment and button sizing.** Added --control-h and friends to
tokens.css; every button, input and select takes its height from them,
so a mixed row is flush by construction rather than by per-instance
nudging. Icon buttons are square at that height. Added .btn-row,
.card__header/.card__footer and .grid so pages stop carrying inline
styles, and moved every admin page onto them.

**Settings needed structure.** The user settings page is now tabbed
(Account / Models / Appearance / Security) using radio inputs and
sibling selectors -- no JavaScript, and the browser keeps the chosen tab
across a re-render.

Caught while checking: the chat.css surgery had deleted the attachment,
chip and dropzone rules. Restored, and there is now a check that every
literal class used in a template has a CSS rule.

197 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 12:59:52 +02:00
parent bdce2764b1
commit 7b67568f2c
30 changed files with 1264 additions and 536 deletions
+26 -4
View File
@@ -19,7 +19,7 @@ lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
pytest # 186 tests, ~7s
pytest # 197 tests, ~7s
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate all SVG artwork
python scripts/fetch_vendor.py # verify vendored JS against the lockfile
@@ -35,9 +35,10 @@ redesign, not a tweak.
committed under `web/static/vendor/`.
2. **Nothing loads from a CDN at runtime.** A self-hosted tool must work
offline and must not report page views to a third party.
3. **No hard-coded colours outside `tokens.css`.** Every colour, space and
radius resolves through a CSS variable. That is what makes a new theme one
new block rather than an audit of every stylesheet.
3. **No hard-coded values outside `tokens.css`.** Every colour, space, radius
and control height resolves through a CSS variable. `--control-h` is why
buttons, inputs and selects line up: they all take their height from it, so
a mixed row is flush by construction rather than by nudging.
4. **Additive-only schema changes.** SQLite only, no Alembic. `init_db()` runs
`db/migrations.py:sync_schema()`, which creates missing tables *and* adds
missing columns by diffing the models against the database. Renames, drops
@@ -190,6 +191,27 @@ null in the composer; `files.claim()` binds them, and only unclaimed rows owned
by that user, so a forged id cannot pull in someone else's file. Abandoned ones
are swept at startup.
**Chats are created lazily.** There is no endpoint that makes an empty chat.
"New chat" is a link to `/chat`, which renders a composer with no row behind
it; `POST /api/chats/start` writes the chat together with its first message.
That is why an opened-and-abandoned chat never appears in the sidebar. Tests
that just need a chat use the `make_chat` fixture rather than the HTTP flow.
**Route order matters for static path segments.** FastAPI matches in
registration order, so `/admin/models/bulk` must be registered *before*
`/admin/models/{model_id}` or "bulk" is parsed as a model id and 404s. This has
already been a bug once.
**Pinning is not ordering.** The model picker is always in the administrator's
`position` order. Pinned models get shortcuts in the chat sidebar and nothing
else -- a picker whose order differs from the admin screen is just confusing.
**System prompts are precedence, not concatenation.** chat > model > instance,
most specific wins outright (`services/chat.py:effective_system_prompt`).
Stacking them reads well in a settings screen and badly in practice: two layers
that disagree give the model contradictory instructions and nobody can tell
which is losing.
**JSON columns need reassignment.** `user.settings_json["theme"] = x` on a
plain dict is not detected. The columns use `MutableDict` (`db/types.py`), but
the safe habit is `obj.field = {**obj.field, "k": v}`.
+4 -2
View File
@@ -31,8 +31,10 @@ 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, per-chat system prompt and
sampling settings
copy and regenerate, automatic chat titles. Chats are created when you send
the first message, so an abandoned one never clutters the sidebar
- **System prompts** — instance-wide, per-model and per-chat, with the most
specific winning outright
- **Reasoning display** — thinking from reasoning models streams into its own
collapsible block, labelled with how long it took, and is never replayed as
context
+2
View File
@@ -62,6 +62,7 @@ async def save_general(
user: AdminUser,
instance_name: str = Form("LLeMbas"),
allow_signup: bool = Form(False),
system_prompt: str = Form(""),
) -> Response:
"""Save instance settings.
@@ -73,6 +74,7 @@ async def save_general(
{
"instance_name": instance_name.strip()[:120] or "LLeMbas",
"allow_signup": allow_signup,
"system_prompt": system_prompt.strip()[:8000],
},
)
log.info("registration %s by %s", "opened" if allow_signup else "closed", user.email)
+33 -27
View File
@@ -59,12 +59,43 @@ async def models_page(request: Request, db: Db, user: AdminUser, saved: str = ""
"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 "",
"instance_prompt": settings_store.get(db, "system_prompt") or "",
"capabilities": CAPABILITIES,
"saved": saved,
},
)
# Registered BEFORE /{model_id}: FastAPI matches in registration order, so
# with the parameterised route first, "bulk" is captured as a model id and
# the handler 404s on a model that does not exist.
@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
)
@router.post("/admin/models/{model_id}")
async def update_model(
db: Db,
@@ -72,6 +103,7 @@ async def update_model(
model_id: str,
display_name: str = Form(""),
description: str = Form(""),
system_prompt: str = Form(""),
enabled: bool = Form(False),
pinned: bool = Form(False),
public: bool = Form(False),
@@ -82,6 +114,7 @@ async def update_model(
model.display_name = display_name.strip()[:300]
model.description = description.strip()[:2000]
model.system_prompt = system_prompt.strip()[:8000]
model.enabled = enabled
model.pinned = pinned
model.public = public
@@ -165,33 +198,6 @@ async def delete_model_image(db: Db, user: AdminUser, model_id: str) -> Response
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:
+63 -5
View File
@@ -42,9 +42,18 @@ def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
return chat
@router.post("", dependencies=[Depends(require_permission("chat.create"))])
async def create_chat(db: Db, user: RequiredUser, folder_id: str = Form("")) -> Response:
def _new_chat(db: DBSession, user: User, *, folder_id: str = "", model_id: str = "") -> Chat:
"""Create a chat row, resolving which model it should use."""
chosen = None
if model_id:
match = next(
(m for m in chat_service.available_models(db, user) if m.model_id == model_id), None
)
if match is not None:
chosen = (match.model_id, match.connection_id)
if chosen is None:
chosen = chat_service.default_model(db, user)
chat = Chat(
user_id=user.id,
folder_id=folder_id or None,
@@ -53,14 +62,48 @@ async def create_chat(db: Db, user: RequiredUser, folder_id: str = Form("")) ->
)
db.add(chat)
db.commit()
return chat
@router.post("/start", dependencies=[Depends(require_permission("chat.create"))])
async def start_chat(
db: Db,
user: RequiredUser,
content: str = Form(""),
file_ids: list[str] = Form(default=[]),
folder_id: str = Form(""),
model_id: str = Form(""),
) -> Response:
"""Create a chat from its first message.
Chats are made here rather than by a "New chat" button so that an opened-
and-abandoned chat never exists: the row appears only once there is
something in it. The reply then streams the same way as any other, because
/chat/{id} renders the unfinished assistant message with its sse-connect.
"""
content = content.strip()
if not content and not file_ids:
return Response(status_code=status.HTTP_204_NO_CONTENT)
chat = _new_chat(db, user, folder_id=folder_id, model_id=model_id)
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
if file_ids:
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
# HX-Redirect rather than a swap: a new chat is a new URL, and the address
# bar has to follow so the chat can be reloaded or bookmarked.
response = Response(status_code=status.HTTP_204_NO_CONTENT)
response.headers["HX-Redirect"] = f"/chat/{chat.id}"
return response
# There is deliberately no route that creates an empty chat. Starting one is
# navigation to /chat (optionally ?model=...), and the row is written by
# /start when the first message is actually sent.
@router.post("/{chat_id}/messages")
async def post_message(
request: Request,
@@ -103,6 +146,9 @@ async def post_message(
"assistant_message": assistant_message,
"chat": chat,
"user": user,
"models_by_id": {
m.model_id: m for m in chat_service.available_models(db, user)
},
},
)
@@ -281,6 +327,9 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
# template shares both roles, and a missing `user` would only
# blow up on whichever branch is not being exercised here.
"user": db.get(User, chat.user_id),
"models_by_id": {
m.model_id: m for m in chat_service.available_models(db, None)
},
}
)
title_html = templates.get_template("chat/_title_oob.html").render(
@@ -432,7 +481,16 @@ async def regenerate(
return templates.TemplateResponse(
request,
"chat/_message.html",
{"request": request, "message": message, "chat": chat, "body_html": "", "user": user},
{
"request": request,
"message": message,
"chat": chat,
"body_html": "",
"user": user,
"models_by_id": {
m.model_id: m for m in chat_service.available_models(db, user)
},
},
)
+39 -8
View File
@@ -25,15 +25,16 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
"""
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,
# For the sidebar shortcuts only. The picker lists `models` in the
# administrator's order, pinned or not.
"pinned_models": [m for m in models if m.pinned],
"current_model": current,
# Assistant bubbles show the avatar of the model that wrote them, which
# may not be the model the chat is set to now. Keyed by model_id, the
# denormalised value stored on each message.
"models_by_id": {m.model_id: m for m in models},
}
@@ -74,14 +75,26 @@ async def home(user: RequiredUser):
@router.get("/chat")
async def chat_index(request: Request, db: Db, user: RequiredUser):
async def chat_index(request: Request, db: Db, user: RequiredUser, model: str = ""):
"""A composer with no chat behind it yet.
`?model=` preselects one, which is how the pinned shortcuts work without
creating a row for a chat that may never be sent.
"""
context = _chat_context(db, user, None)
preselected = next(
(m for m in context["models"] if m.model_id == model), None
) or (context["models"][0] if context["models"] else None)
return render(
request,
"chat/index.html",
{
"chat": None,
"messages": [],
**_chat_context(db, user, None),
"bodies": {},
**context,
"current_model": preselected,
**_sidebar_context(db, user),
},
)
@@ -108,6 +121,22 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
if message.role == "assistant" and message.content
}
# What the chat would use if its own prompt were empty, so the settings
# panel can show it as placeholder text rather than leaving the user to
# guess what "inherited" means.
inherited, inherited_from = "", ""
current = next(
(m for m in chat_service.available_models(db, user) if m.model_id == chat.model_id), None
)
if current is not None and (current.system_prompt or "").strip():
inherited, inherited_from = current.system_prompt.strip(), "model"
else:
from lembas.services import settings_store
instance_prompt = (settings_store.get(db, "system_prompt") or "").strip()
if instance_prompt:
inherited, inherited_from = instance_prompt, "instance"
return render(
request,
"chat/index.html",
@@ -115,6 +144,8 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
"chat": chat,
"messages": messages,
"bodies": bodies,
"inherited_prompt": inherited,
"inherited_from": inherited_from,
**_chat_context(db, user, chat),
**_sidebar_context(db, user),
},
+4
View File
@@ -104,6 +104,10 @@ class Model(UUIDPrimaryKey, Timestamps, Base):
# image cannot become a request to a third party on every page render.
image_path: Mapped[str] = mapped_column(String(300), default="")
# Applied to chats using this model when the chat has none of its own.
# See services.chat.effective_system_prompt for the precedence.
system_prompt: Mapped[str] = mapped_column(Text, default="")
# Endpoints do not reliably advertise capabilities, so these are admin
# overrides. Recognised keys: vision, tools, reasoning.
capabilities_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+39 -7
View File
@@ -129,6 +129,32 @@ def message_payload(message: Message, *, vision: bool) -> dict[str, Any]:
return {"role": message.role, "content": parts}
def effective_system_prompt(db: DBSession, chat: Chat) -> str:
"""The system prompt a chat actually runs with.
Three layers, most specific wins outright:
chat > model > instance
Precedence rather than concatenation. Stacking them reads well in a
settings screen and badly in practice: the moment two layers disagree the
model gets contradictory instructions and nobody can tell which one is
losing. With precedence, "why is it behaving like this" has one answer.
"""
from lembas.services import settings_store
if chat.system_prompt.strip():
return chat.system_prompt.strip()
model = db.scalar(
select(Model).where(Model.model_id == chat.model_id).order_by(Model.position)
)
if model is not None and (model.system_prompt or "").strip():
return model.system_prompt.strip()
return (settings_store.get(db, "system_prompt") or "").strip()
def build_messages(
db: DBSession, chat: Chat, *, upto: Message | None = None, vision: bool = False
) -> list[dict]:
@@ -138,8 +164,9 @@ def build_messages(
everything after it.
"""
payload: list[dict[str, Any]] = []
if chat.system_prompt.strip():
payload.append({"role": ROLE_SYSTEM, "content": chat.system_prompt.strip()})
system = effective_system_prompt(db, chat)
if system:
payload.append({"role": ROLE_SYSTEM, "content": system})
history = db.scalars(
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
@@ -209,18 +236,23 @@ def default_model(db: DBSession, user=None) -> tuple[str, str] | None:
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]
# First in the administrator's ordering. Pinning is a sidebar shortcut, not
# a reordering, so it deliberately does not influence this.
chosen = sorted(reachable, key=lambda m: (m.position, m.model_id))[0]
return chosen.model_id, chosen.connection_id
def available_models(db: DBSession, user=None) -> list[Model]:
"""Models this user may start a chat with, pinned first."""
"""Models this user may start a chat with, in the administrator's order.
Pinning does NOT hoist a model up this list: pinned models get their own
shortcuts in the sidebar, and a picker whose order silently differs from
the one configured in the admin screen is just confusing.
"""
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))
return sorted(reachable, key=lambda m: (m.position, m.model_id))
def fallback_title(text: str) -> str:
+3
View File
@@ -29,6 +29,9 @@ def _defaults() -> dict[str, Any]:
# until an administrator approves them. Reserved for the users pass.
"require_approval": False,
"instance_name": "LLeMbas",
# Applied to every chat that has no model or chat prompt of its
# own. See services.chat.effective_system_prompt.
"system_prompt": "",
}
+161 -59
View File
@@ -1,27 +1,34 @@
/* Administration screens. */
/* Settings and administration screens. */
.admin-scroll {
/* --- Page scaffolding ------------------------------------------------------ */
.admin-scroll,
.tabs__body {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
.page,
.admin-page {
max-width: 46rem;
max-width: 48rem;
margin: 0 auto;
padding: var(--sp-6) var(--sp-5) var(--sp-12);
}
.admin-lede {
.page-header { margin-bottom: var(--sp-6); }
.admin-lede,
.page-header__lede {
color: var(--ink-muted);
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
margin-bottom: var(--sp-6);
max-width: 42rem;
margin: 0 0 var(--sp-6);
max-width: 44rem;
}
.admin-section-title {
.admin-section-title,
.section-title {
display: flex;
align-items: center;
gap: var(--sp-2);
@@ -29,6 +36,60 @@
margin: var(--sp-8) 0 var(--sp-4);
}
/* --- Tabs ------------------------------------------------------------------
Radio inputs plus sibling selectors: no JavaScript, and the browser keeps
the chosen tab across a re-render.
*/
.tabs { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.tabs__bar {
display: flex;
align-items: center;
gap: var(--sp-1);
padding: 0 var(--sp-5);
border-bottom: 1px solid var(--border);
background: var(--bg);
flex: none;
overflow-x: auto;
scrollbar-width: none;
}
.tabs__tab {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
height: var(--control-h-lg);
padding: 0 var(--sp-4);
border-bottom: 2px solid transparent;
color: var(--ink-muted);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.tabs__tab:hover { color: var(--ink); }
.tabs__panel { display: none; }
/* Each radio activates its own label and its own panel. Written out because
CSS has no way to derive one from the other. */
#tab-account:checked ~ label[for="tab-account"],
#tab-models:checked ~ label[for="tab-models"],
#tab-appearance:checked ~ label[for="tab-appearance"],
#tab-security:checked ~ label[for="tab-security"] {
color: var(--ink);
border-bottom-color: var(--accent);
}
.tabs__bar:has(#tab-account:checked) ~ .tabs__body [data-tab="tab-account"],
.tabs__bar:has(#tab-models:checked) ~ .tabs__body [data-tab="tab-models"],
.tabs__bar:has(#tab-appearance:checked) ~ .tabs__body [data-tab="tab-appearance"],
.tabs__bar:has(#tab-security:checked) ~ .tabs__body [data-tab="tab-security"] {
display: block;
}
.tabs__tab:has(:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; }
/* --- Cards ----------------------------------------------------------------- */
.card {
background: var(--surface);
border: 1px solid var(--border);
@@ -36,27 +97,21 @@
padding: var(--sp-5);
margin-bottom: var(--sp-4);
}
.card:last-child { margin-bottom: 0; }
.card__title {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-md);
margin-bottom: var(--sp-4);
margin-bottom: var(--sp-2);
}
.form-grid { display: block; }
.form-grid .field:last-of-type { margin-bottom: 0; }
.field--actions { margin-top: var(--sp-5); margin-bottom: 0; }
.connection__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
.card__lede {
color: var(--ink-muted);
font-size: var(--text-sm);
margin-bottom: var(--sp-4);
flex-wrap: wrap;
line-height: var(--leading-relaxed);
}
.connection__footer {
.card__footer {
display: flex;
align-items: center;
justify-content: space-between;
@@ -66,8 +121,36 @@
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
flex-wrap: wrap;
}
/* Reachable status at a glance: green working, red errored, grey disabled. */
/* Legacy aliases so existing admin templates keep their spacing. */
.form-grid { display: block; }
.connection__head { display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3); margin-bottom: var(--sp-4); flex-wrap: wrap; }
.connection__footer { display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3); margin-top: var(--sp-5); padding-top: var(--sp-4);
border-top: 1px solid var(--border); flex-wrap: wrap; }
.field--actions { margin-top: var(--sp-5); margin-bottom: 0; }
/* --- Definition lists ------------------------------------------------------ */
.detail-list {
display: grid;
grid-template-columns: minmax(6rem, auto) 1fr;
gap: var(--sp-2) var(--sp-4);
margin: 0;
font-size: var(--text-sm);
}
.detail-list dt { color: var(--ink-muted); font-weight: 500; }
.detail-list dd { margin: 0; }
/* --- Status ---------------------------------------------------------------- */
.status-dot {
width: 0.55rem;
height: 0.55rem;
@@ -79,44 +162,25 @@
.status-dot.is-bad { background: var(--danger); }
.status-dot.is-off { background: var(--ink-faint); }
.model-list { list-style: none; margin: 0; padding: 0; }
.model-list__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-2) 0;
border-bottom: 1px solid var(--border);
}
.model-list__item:last-child { border-bottom: 0; }
.model-list__id {
font-family: var(--font-mono);
font-size: var(--text-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-item.is-disabled {
opacity: 0.45;
cursor: default;
}
.nav-item.is-disabled:hover { background: none; color: var(--ink-muted); }
/* --- Model list ----------------------------------------------------------- */
/* --- Model list ------------------------------------------------------------ */
.bulk-bar {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
padding: var(--sp-3);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
margin-bottom: var(--sp-4);
}
.bulk-bar .model-rows { flex-basis: 100%; margin-top: var(--sp-3); }
.bulk-bar__label { font-size: var(--text-sm); color: var(--ink-muted); margin-right: var(--sp-1); }
.model-rows {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--surface);
}
.model-row {
display: flex;
@@ -124,31 +188,43 @@
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.is-off { opacity: 0.5; }
.model-row__check { accent-color: var(--accent); width: 1rem; height: 1rem; flex: none; }
.model-row__avatar { flex: none; }
.model-row__avatar { flex: none; width: 2rem; height: 2rem; }
.model-row__main { flex: 1; min-width: 0; }
.model-row__title {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
margin-bottom: 0.1rem;
}
.model-row__id {
display: block;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-muted);
color: var(--ink-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.model-row__actions { display: flex; gap: var(--sp-1); flex: none; align-items: center; }
.model-row__actions { display: flex; align-items: center; gap: var(--sp-1); flex: none; }
/* --- Permission and checkbox grids ---------------------------------------- */
.model-list { list-style: none; margin: 0; padding: 0; }
.model-list__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border);
}
.model-list__item:first-child { padding-top: 0; }
.model-list__item:last-child { border-bottom: 0; padding-bottom: 0; }
/* --- Permission grids ------------------------------------------------------ */
.checkbox-row {
display: flex;
flex-wrap: wrap;
@@ -159,26 +235,52 @@
.perm-row {
align-items: flex-start;
padding: var(--sp-2) 0;
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border);
}
.perm-row:last-child { border-bottom: 0; }
.perm-row input { margin-top: 0.2rem; }
.perm-row input { margin-top: 0.15rem; }
.perm-row__desc {
display: block;
font-size: var(--text-xs);
color: var(--ink-faint);
font-weight: 400;
line-height: var(--leading-normal);
margin-top: 0.1rem;
}
.input--file {
padding: 0.35rem;
.perm-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); }
.perm-list__item {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-sm);
}
.perm-list__state {
display: grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
border-radius: var(--radius-full);
background: var(--surface-active);
color: var(--ink-faint);
flex: none;
}
.perm-list__state.is-on { background: var(--success-soft); color: var(--success); }
/* --- Misc ------------------------------------------------------------------ */
.input--file {
height: auto;
padding: 0.35rem;
font-size: var(--text-xs);
background: var(--surface-raised);
}
.field__hint code {
.nav-item.is-disabled { opacity: 0.4; cursor: default; }
.nav-item.is-disabled:hover { background: none; color: var(--ink-muted); }
.field__hint code,
.card__lede code {
font-family: var(--font-mono);
font-size: 0.92em;
padding: 0.05em 0.3em;
+169 -33
View File
@@ -84,29 +84,37 @@ button, input, textarea, select {
/* The leaf is a filled silhouette, not a stroked pictogram. */
.icon--leaf { fill: currentColor; stroke: none; }
/* --- Buttons -------------------------------------------------------------- */
/* --- Buttons ---------------------------------------------------------------
Every variant is the same height and vertically centres its contents, so a
row of mixed buttons lines up without per-instance nudging. Icon buttons are
square at that height rather than a different shape.
*/
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding: 0.5rem 0.9rem;
height: var(--control-h);
padding: 0 var(--control-px);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-raised);
color: var(--ink);
font-size: var(--text-sm);
font-weight: 500;
line-height: 1;
cursor: pointer;
white-space: nowrap;
text-decoration: none;
transition: background var(--transition-fast), border-color var(--transition-fast),
color var(--transition-fast);
white-space: nowrap;
}
.btn:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--border-strong);
color: var(--ink);
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn--primary {
background: var(--accent);
@@ -116,37 +124,58 @@ button, input, textarea, select {
.btn--primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: var(--accent-ink);
}
.btn--danger { color: var(--danger); border-color: var(--border); }
.btn--danger { color: var(--danger); }
.btn--danger:hover:not(:disabled) {
background: var(--danger-soft);
border-color: var(--danger);
color: var(--danger);
}
.btn--ghost { background: transparent; border-color: transparent; }
.btn--ghost:hover:not(:disabled) { background: var(--surface-hover); border-color: transparent; }
/* Square, and the same height as everything beside it. */
.btn--icon {
padding: 0.4rem;
border-radius: var(--radius);
width: var(--control-h);
padding: 0;
background: transparent;
border-color: transparent;
color: var(--ink-muted);
}
.btn--icon:hover:not(:disabled) { background: var(--surface-hover); color: var(--ink); }
.btn--icon.btn--primary {
color: var(--accent-ink);
background: var(--accent);
border-color: var(--accent);
}
.btn--sm { height: var(--control-h-sm); padding: 0 var(--control-px-sm); font-size: var(--text-xs); }
.btn--sm.btn--icon { width: var(--control-h-sm); padding: 0; }
.btn--lg { height: var(--control-h-lg); padding: 0 var(--sp-5); font-size: var(--text-base); }
.btn--block { width: 100%; }
.btn--sm { padding: 0.3rem 0.6rem; font-size: var(--text-xs); }
.btn--grow { flex: 1; }
/* --- Forms ---------------------------------------------------------------- */
/* Rows of buttons: gap and alignment in one place, not per instance. */
.btn-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.btn-row--end { justify-content: flex-end; }
.spacer { flex: 1; }
/* --- Forms -----------------------------------------------------------------
Inputs share the button height, so a control row is flush by construction.
*/
.field { margin-bottom: var(--sp-4); }
.field:last-child { margin-bottom: 0; }
.field__label {
display: block;
margin-bottom: var(--sp-2);
font-size: var(--text-sm);
font-weight: 500;
color: var(--ink-muted);
color: var(--ink);
}
.field__hint {
margin-top: var(--sp-2);
@@ -156,17 +185,31 @@ button, input, textarea, select {
}
.input,
.textarea,
.select {
width: 100%;
padding: 0.55rem 0.7rem;
height: var(--control-h);
padding: 0 var(--control-px);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-sunken);
color: var(--ink);
font-size: var(--text-base);
font-size: var(--text-sm);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.textarea {
width: 100%;
padding: var(--sp-2) var(--control-px);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-sunken);
color: var(--ink);
font-size: var(--text-sm);
line-height: var(--leading-normal);
resize: vertical;
min-height: 5rem;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.input:focus,
.textarea:focus,
.select:focus {
@@ -175,8 +218,29 @@ button, input, textarea, select {
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input::placeholder, .textarea::placeholder { color: var(--ink-faint); }
.textarea { resize: vertical; min-height: 5rem; line-height: var(--leading-normal); }
.input--mono { font-family: var(--font-mono); font-size: var(--text-sm); }
.input--mono, .textarea--mono { font-family: var(--font-mono); font-size: var(--text-xs); }
.select {
appearance: none;
padding-right: var(--sp-8);
/* Chevron drawn in CSS, so no icon font and no extra element. */
background-image:
linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: right 1.1rem center, right 0.85rem center;
background-size: 0.3rem 0.3rem, 0.3rem 0.3rem;
background-repeat: no-repeat;
cursor: pointer;
}
/* Sits inside a composed control that draws its own frame (the model picker). */
.select--bare {
border-color: transparent;
background-color: transparent;
width: auto;
max-width: 14rem;
padding-left: var(--sp-1);
}
.select--bare:focus { box-shadow: none; }
.checkbox {
display: flex;
@@ -184,8 +248,20 @@ button, input, textarea, select {
gap: var(--sp-2);
font-size: var(--text-sm);
cursor: pointer;
line-height: var(--leading-normal);
}
.checkbox input { accent-color: var(--accent); width: 1rem; height: 1rem; }
.checkbox input {
accent-color: var(--accent);
width: 1rem;
height: 1rem;
flex: none;
cursor: pointer;
}
/* Multi-column form layout, one definition. */
.grid { display: grid; gap: var(--sp-4); }
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); }
/* --- Alerts --------------------------------------------------------------- */
.alert {
@@ -220,12 +296,8 @@ button, input, textarea, select {
.badge--success { background: var(--success-soft); color: var(--success); }
.badge--danger { background: var(--danger-soft); color: var(--danger); }
/* --- Application shell ---------------------------------------------------- */
.shell {
display: flex;
height: 100dvh;
overflow: hidden;
}
/* --- Application shell ----------------------------------------------------- */
.shell { display: flex; height: 100dvh; overflow: hidden; }
.sidebar {
width: var(--sidebar-width);
@@ -234,14 +306,13 @@ button, input, textarea, select {
flex-direction: column;
background: var(--bg-sunken);
border-right: 1px solid var(--border);
transition: margin-left var(--transition);
min-height: 0;
}
.sidebar[hidden] { display: none; }
.sidebar__header {
display: flex;
align-items: center;
gap: var(--sp-2);
height: var(--header-height);
padding: 0 var(--sp-3);
flex: none;
@@ -259,13 +330,20 @@ button, input, textarea, select {
min-width: 0;
}
.sidebar__brand:hover { color: var(--ink); }
.sidebar__brand .brand-mark { width: 1.6rem; height: 1.6rem; flex: none; }
.sidebar__brand .brand-mark { width: 1.65rem; height: 1.65rem; flex: none; }
.sidebar__brand .brand-llm { color: var(--gold); }
.sidebar__actions { padding: 0 var(--sp-3) var(--sp-3); display: flex; gap: var(--sp-2); }
.sidebar__actions {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 0 var(--sp-3) var(--sp-3);
flex: none;
}
.sidebar__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 var(--sp-2) var(--sp-3);
scrollbar-width: thin;
@@ -276,13 +354,18 @@ button, input, textarea, select {
flex: none;
border-top: 1px solid var(--border);
padding: var(--sp-2);
display: flex;
flex-direction: column;
gap: var(--sp-1);
}
.sidebar__tools { display: flex; align-items: center; gap: var(--sp-1); }
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--bg);
}
@@ -306,10 +389,52 @@ button, input, textarea, select {
min-width: 0;
flex: 1;
}
.topbar__spacer { flex: 1; }
.topbar__actions { display: flex; align-items: center; gap: var(--sp-2); flex: none; }
/* --- Sidebar navigation --------------------------------------------------- */
/* The model picker: an avatar and a select sharing one frame, so it reads as a
single control rather than two things that happen to be adjacent. */
.model-select {
display: flex;
align-items: center;
gap: var(--sp-2);
height: var(--control-h);
padding: 0 var(--sp-1) 0 var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
cursor: pointer;
transition: border-color var(--transition-fast);
}
.model-select:hover { border-color: var(--border-strong); }
.model-select:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.model-select__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); }
/* Collapsible settings panel, shared by chat settings and anything like it. */
.panel {
flex: none;
border-bottom: 1px solid var(--border);
background: var(--bg-sunken);
max-height: 60vh;
overflow-y: auto;
}
.panel__inner {
max-width: var(--thread-max-width);
margin: 0 auto;
padding: var(--sp-5);
}
.panel__note {
color: var(--ink-muted);
font-size: var(--text-sm);
font-style: italic;
margin-bottom: var(--sp-4);
}
/* --- Sidebar navigation ---------------------------------------------------- */
.nav-group { margin-bottom: var(--sp-4); }
.nav-group:last-child { margin-bottom: 0; }
.nav-group__label {
display: flex;
align-items: center;
@@ -326,7 +451,8 @@ button, input, textarea, select {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 0.4rem var(--sp-2);
min-height: var(--control-h);
padding: 0 var(--sp-2);
border-radius: var(--radius);
color: var(--ink-muted);
text-decoration: none;
@@ -335,7 +461,11 @@ button, input, textarea, select {
position: relative;
}
.nav-item:hover { background: var(--surface-hover); color: var(--ink); }
.nav-item.is-active { background: var(--surface-active); color: var(--ink); font-weight: 500; }
.nav-item.is-active {
background: var(--surface-active);
color: var(--ink);
font-weight: 500;
}
.nav-item__label {
overflow: hidden;
text-overflow: ellipsis;
@@ -343,11 +473,16 @@ button, input, textarea, select {
flex: 1;
min-width: 0;
}
/* Row actions stay hidden until the row is hovered or focused within, so the
list reads calmly, but they remain keyboard reachable. */
.nav-item__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); flex: none; }
.nav-item--model .nav-item__label { font-weight: 500; }
/* Row actions stay hidden until hover or focus, so the list reads calmly while
remaining keyboard reachable. */
.nav-item__actions {
display: flex;
align-items: center;
gap: 0.1rem;
flex: none;
opacity: 0;
transition: opacity var(--transition-fast);
}
@@ -355,7 +490,8 @@ button, input, textarea, select {
.nav-item:focus-within .nav-item__actions { opacity: 1; }
.nav-empty {
padding: var(--sp-3) var(--sp-2);
padding: var(--sp-2);
margin: 0;
font-size: var(--text-xs);
color: var(--ink-faint);
font-style: italic;
+14 -45
View File
@@ -48,6 +48,7 @@
overflow: hidden;
}
.msg__mark { width: 2rem; height: 2rem; }
.msg__avatar { width: 2rem; height: 2rem; border-radius: var(--radius); }
.msg__initial {
width: 2rem; height: 2rem;
display: grid;
@@ -310,11 +311,10 @@
border-top: 1px solid var(--border);
background: var(--bg);
}
.composer__inner { max-width: var(--thread-max-width); margin: 0 auto; }
.composer__form {
max-width: var(--thread-max-width);
margin: 0 auto;
display: flex;
gap: var(--sp-2);
gap: var(--sp-1);
align-items: flex-end;
padding: var(--sp-2);
border: 1px solid var(--border);
@@ -322,37 +322,33 @@
background: var(--surface);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
/* Attach and send are the same size and sit on the same baseline as the last
line of the textarea, so the control row reads as one object. */
.composer__btn { flex: none; align-self: flex-end; border-radius: var(--radius-full); }
.composer__form:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.composer__input {
flex: 1;
min-width: 0;
border: 0;
background: none;
resize: none;
padding: var(--sp-2);
padding: 0.5rem var(--sp-2);
font-size: var(--text-base);
line-height: var(--leading-normal);
max-height: 20rem;
color: var(--ink);
}
.composer__input:focus { outline: none; }
.composer__send { border-radius: var(--radius-full); padding: 0.55rem 0.7rem; }
.composer__hint {
max-width: var(--thread-max-width);
margin: var(--sp-2) auto 0;
margin: var(--sp-2) 0 0;
font-size: var(--text-xs);
color: var(--ink-faint);
text-align: center;
}
.select--compact {
width: auto;
max-width: 16rem;
padding: 0.3rem 0.5rem;
font-size: var(--text-sm);
}
/* --- Folders -------------------------------------------------------------- */
.folder__row { padding-right: var(--sp-1); }
.folder__toggle {
@@ -386,38 +382,11 @@
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; }
/* --- Attachment chips (composer) ------------------------------------------ */
.composer { position: relative; }
.composer__attachments {
max-width: var(--thread-max-width);
margin: 0 auto var(--sp-2);
margin: 0 0 var(--sp-2);
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
@@ -451,8 +420,6 @@
.chip__meta { font-size: var(--text-xs); color: var(--ink-faint); }
.chip__warning { font-size: var(--text-xs); color: var(--danger); }
.composer__attach { flex: none; align-self: flex-end; }
/* --- Drag and drop -------------------------------------------------------- */
.dropzone-overlay {
position: absolute;
@@ -534,7 +501,9 @@
}
/* --- Theme toggle --------------------------------------------------------- */
/* Only the icon for the theme you would switch TO is shown. */
/* Only the icon for the theme you would switch TO is shown. Both live in the
same button, so the wrapper must not add a line box of its own. */
.theme-icon { display: flex; }
:root[data-theme="moria"] .theme-icon--dark { display: none; }
:root[data-theme="shire"] .theme-icon--light { display: none; }
+14 -2
View File
@@ -51,10 +51,22 @@
--radius-xl: 18px;
--radius-full: 999px;
/*
--- Controls ----------------------------------------------------------
Every button, input and select resolves its height from these. That is the
whole reason things line up: a row of mixed controls has one height, not
whatever each element's padding and font happened to add up to.
*/
--control-h: 2.25rem;
--control-h-sm: 1.75rem;
--control-h-lg: 2.75rem;
--control-px: 0.75rem;
--control-px-sm: 0.5rem;
/* --- Layout ----------------------------------------------------------- */
--sidebar-width: 17rem;
--sidebar-width: 17.5rem;
--thread-max-width: 48rem;
--header-height: 3.25rem;
--header-height: 3.5rem;
--transition-fast: 120ms ease;
--transition: 200ms ease;
+14
View File
@@ -211,6 +211,20 @@
event.preventDefault();
var source = document.getElementById(copy.dataset.copy);
if (source) copyText(source.textContent.trim(), copy);
return;
}
/* Show/hide a panel by selector, so templates do not each carry their own
inline toggle script. */
var toggle = event.target.closest("[data-toggle]");
if (toggle) {
event.preventDefault();
var panel = document.querySelector(toggle.dataset.toggle);
if (!panel) return;
var nowOpen = panel.hasAttribute("hidden");
panel.toggleAttribute("hidden");
toggle.setAttribute("aria-expanded", nowOpen ? "true" : "false");
toggle.classList.toggle("is-active", nowOpen);
}
});
@@ -7,7 +7,7 @@
#}
<section class="card connection" id="connection-{{ connection.id }}">
<form method="post" action="/admin/connections/{{ connection.id }}" class="form-grid">
<div class="connection__head">
<div class="card__header">
<div class="row" style="gap: var(--sp-2); min-width: 0">
<span class="status-dot {{ 'is-ok' if connection.enabled and not connection.last_error
else 'is-bad' if connection.last_error else 'is-off' }}"
@@ -21,7 +21,7 @@
{% endif %}
</div>
<div class="row" style="gap: var(--sp-2)">
<div class="btn-row">
<button class="btn btn--sm" type="submit"
hx-post="/admin/connections/{{ connection.id }}/test"
hx-target="#connection-{{ connection.id }}" hx-swap="outerHTML"
@@ -80,7 +80,7 @@
</label>
</div>
<div class="connection__footer">
<div class="card__footer">
<span class="text-xs faint">
{% if connection.last_checked_at %}
Last checked {{ connection.last_checked_at.strftime("%Y-%m-%d %H:%M") }} UTC
@@ -18,7 +18,7 @@
<section class="card">
<h2 class="card__title">Add a connection</h2>
<form method="post" action="/admin/connections" class="form-grid">
<form method="post" action="/admin/connections" >
<div class="field">
<label class="field__label" for="new-name">Name</label>
<input class="input" id="new-name" name="name" required placeholder="Local LM Studio">
@@ -43,7 +43,7 @@
</p>
</div>
<div class="field field--actions">
<div class="btn-row" style="margin-top: var(--sp-5)">
<button class="btn btn--primary" type="submit">
{{ icon("plus", "icon--sm") }} Add and load models
</button>
+16 -1
View File
@@ -26,6 +26,21 @@
</div>
</section>
<section class="card">
<h2 class="card__title">Default system prompt</h2>
<p class="card__lede">
Applied to every chat that does not have a prompt of its own. A model's
prompt overrides this, and a chat's prompt overrides both — most specific
wins outright rather than the three being stacked together.
</p>
<div class="field">
<label class="field__label visually-hidden" for="system-prompt">System prompt</label>
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="5"
placeholder="You are a helpful assistant.">{{ values.system_prompt }}</textarea>
<p class="field__hint">Leave empty to send no system prompt at all.</p>
</div>
</section>
<section class="card">
<h2 class="card__title">
Registration
@@ -69,6 +84,6 @@
</p>
</section>
<button class="btn btn--primary" type="submit">Save settings</button>
<div class="btn-row"><button class="btn btn--primary" type="submit">Save settings</button></div>
</form>
{% endblock %}
+6 -4
View File
@@ -40,7 +40,7 @@
{% endfor %}
</div>
{% endfor %}
<button class="btn btn--primary" type="submit">Save baseline</button>
<div class="btn-row"><button class="btn btn--primary" type="submit">Save baseline</button></div>
</form>
</section>
@@ -49,7 +49,7 @@
</h2>
<section class="card">
<form method="post" action="/admin/groups" class="row" style="gap: var(--sp-2)">
<form method="post" action="/admin/groups" class="btn-row">
<input class="input" name="name" placeholder="New group name" required
aria-label="New group name">
<button class="btn btn--primary" type="submit">
@@ -71,7 +71,7 @@
{% 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)">
<div class="card__header">
<strong>{{ group.name }}</strong>
<span class="text-xs faint">
{{ group.users|length }} member{{ '' if group.users|length == 1 else 's' }},
@@ -150,10 +150,12 @@
{% endif %}
</div>
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save {{ group.name }}</button>
</div>
</form>
<div class="connection__footer">
<div class="card__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 }}”?')">
+25 -8
View File
@@ -8,8 +8,9 @@
{% block admin_content %}
<p class="admin-lede">
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.
users see in the picker. <strong>Pinning</strong> does not reorder anything —
it puts a shortcut in the chat sidebar. The <strong>default</strong> is what a
new chat starts with.
</p>
{% if saved %}
@@ -26,12 +27,14 @@
</div>
{% else %}
<form method="post" action="/admin/models/bulk" class="bulk-bar">
<span class="text-sm muted">With selected:</span>
<form method="post" action="/admin/models/bulk">
<div class="bulk-bar">
<span class="bulk-bar__label">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>
<div class="model-rows">
{% for model in models %}
@@ -82,7 +85,7 @@
{% for model in models %}
<section class="card" id="model-{{ model.id }}">
<div class="row row--between" style="margin-bottom: var(--sp-4)">
<div class="card__header">
<div class="row" style="gap: var(--sp-3); min-width: 0">
{{ model_avatar(model, cls="model-row__avatar") }}
<div style="min-width: 0">
@@ -111,6 +114,18 @@
placeholder="What is this model good at?">{{ model.description }}</textarea>
</div>
<div class="field">
<label class="field__label" for="sp-{{ model.id }}">System prompt</label>
<textarea class="textarea" id="sp-{{ model.id }}" name="system_prompt" rows="3"
placeholder="{{ instance_prompt or 'No instance prompt is set.' }}"
>{{ model.system_prompt }}</textarea>
<p class="field__hint">
Used by chats on this model that have no prompt of their own.
{% if instance_prompt %}Leave empty to fall back to the instance prompt.
{% else %}Leave empty to send no system prompt.{% endif %}
</p>
</div>
<div class="field">
<span class="field__label">Capabilities</span>
<div class="checkbox-row">
@@ -138,7 +153,7 @@
</label>
<label class="checkbox">
<input type="checkbox" name="pinned" value="true" {{ 'checked' if model.pinned }}>
<span>Pinned — offered first in the picker</span>
<span>Pinned — shortcut in the chat sidebar</span>
</label>
</div>
</div>
@@ -169,12 +184,14 @@
{% endif %}
</div>
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save {{ model.label }}</button>
</div>
</form>
<div class="connection__footer">
<div class="card__footer">
<form method="post" action="/admin/models/{{ model.id }}/image"
enctype="multipart/form-data" class="row" style="gap: var(--sp-2)">
enctype="multipart/form-data" class="btn-row">
<input class="input input--file" type="file" name="image"
accept="image/png,image/jpeg,image/webp,image/gif" required
aria-label="Model image">
+7 -6
View File
@@ -16,7 +16,7 @@
<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)">
<form method="get" action="/admin/users" class="btn-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>
@@ -50,7 +50,9 @@
{% endfor %}
</select>
</div>
<div class="btn-row">
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button>
</div>
</form>
</details>
@@ -61,7 +63,7 @@
{% 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="card__header">
<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>
@@ -135,12 +137,11 @@
</div>
{% endif %}
<button class="btn btn--primary" type="submit">Save</button>
<div class="btn-row"><button class="btn btn--primary" type="submit">Save</button></div>
</form>
<div class="connection__footer">
<form method="post" action="/admin/users/{{ account.id }}/password" class="row"
style="gap: var(--sp-2)">
<div class="card__footer">
<form method="post" action="/admin/users/{{ account.id }}/password" class="btn-row">
<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 }}">
@@ -0,0 +1,84 @@
{% from "_macros.html" import icon %}
{#
The composer, used both inside an existing chat and on /chat where no chat
row exists yet.
The only difference is where it posts. With a chat, the turn is appended to
the thread in place; without one, /api/chats/start creates the chat and
redirects, and the reply streams on arrival because the page renders the
unfinished assistant message with its sse-connect. That is what stops an
opened-and-abandoned chat ever being written to the database.
#}
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}>
{% if can.get("files.upload") %}
<form id="upload-form" hx-post="/api/files{% if chat %}?chat_id={{ chat.id }}{% endif %}"
hx-target="#attachments" hx-swap="beforeend"
hx-encoding="multipart/form-data" hx-on::after-request="this.reset()">
<input class="visually-hidden" type="file" name="file" id="file-input" multiple
accept="image/*,.pdf,.txt,.md,.csv,.json,.py,.js,.ts,.rs,.go,.sh,.sql,.yaml,.yml,.toml,.log"
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
</form>
{% endif %}
<div class="composer__inner">
<div class="composer__attachments" id="attachments"></div>
<form class="composer__form"
{% if chat %}
hx-post="/api/chats/{{ chat.id }}/messages"
hx-target="#thread" hx-swap="beforeend"
hx-on::after-request="if (event.detail.successful) {
this.reset();
document.getElementById('attachments').replaceChildren();
window.lembas.autosize(this.querySelector('textarea'));
window.lembas.scrollThread(true);
}"
{% else %}
hx-post="/api/chats/start" hx-swap="none"
{% endif %}>
{# The chips live outside this form, so their hidden inputs are pulled in
explicitly at submit time. #}
<div hx-include="#attachments" hidden></div>
{% if not chat and current_model %}
<input type="hidden" name="model_id" value="{{ current_model.model_id }}">
{% endif %}
{% if can.get("files.upload") %}
<button class="btn btn--icon composer__btn" type="button"
aria-label="Attach a file" title="Attach a file"
onclick="document.getElementById('file-input').click()">
{{ icon("attach") }}
</button>
{% endif %}
<textarea class="composer__input" name="content" rows="1"
data-autosize data-max-height="320" data-composer-input
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
<button class="btn btn--primary btn--icon composer__btn" type="submit"
aria-label="Send">
{{ icon("send") }}
</button>
</form>
<p class="composer__hint">
Enter to send, Shift+Enter for a new line.
{% if can.get("files.upload") %}
Drag files in, or paste an image.
{% if current_model and not current_model.capabilities_json.get("vision") %}
<strong>{{ current_model.label }} has no vision</strong>, so images
will not be sent — documents still will.
{% endif %}
{% endif %}
</p>
</div>
{% if can.get("files.upload") %}
<div class="dropzone-overlay" aria-hidden="true">
{{ icon("attach", "icon--lg") }}
<span>Drop to attach</span>
</div>
{% endif %}
</div>
+16 -3
View File
@@ -1,4 +1,5 @@
{% from "_macros.html" import icon, mark %}
{% from "_macros.html" import icon, mark, model_avatar %}
{% set speaking_model = (models_by_id | default({})).get(message.model_id) %}
{#
One message bubble, in either of two states.
@@ -22,7 +23,14 @@
<div class="msg__gutter" aria-hidden="true">
{% if message.role == "assistant" %}
{# The model that actually wrote this turn, which is not necessarily the
one the chat is set to now. Falls back to the LLeMbas mark when the
model has been removed or has no image of its own. #}
{% if speaking_model %}
{{ model_avatar(speaking_model, cls="msg__avatar") }}
{% else %}
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
{% endif %}
{% else %}
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
{% endif %}
@@ -31,9 +39,14 @@
<div class="msg__main">
<header class="msg__meta">
<span class="msg__author">
{{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }}
{% if message.role == "assistant" %}
{{ speaking_model.label if speaking_model else "LLeMbas" }}
{% else %}
{{ user.name or "You" }}
{% endif %}
</span>
{% if message.model_id %}
{% if message.model_id and (not speaking_model or speaking_model.display_name) %}
{# Only worth showing when it adds something the author line does not. #}
<span class="msg__model" title="{{ message.model_id }}">{{ message.model_id }}</span>
{% endif %}
</header>
+49 -117
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% from "_macros.html" import icon, mark %}
{% from "_macros.html" import icon, mark, model_avatar %}
{% block title %}{{ chat.title if chat else "Chats" }} - LLeMbas{% endblock %}
{% block title %}{{ chat.title if chat else "New chat" }} - LLeMbas{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
@@ -20,84 +20,94 @@
{{ icon("sidebar") }}
</button>
{% if chat %}
<h1 class="topbar__title"><span id="chat-title">{{ chat.title }}</span></h1>
<h1 class="topbar__title">
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
</h1>
{% 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">
{% if pinned_models %}
<optgroup label="Pinned">
{% for model in pinned_models %}
<div class="topbar__actions">
{% if models %}
{% if can.get("chat.model_select") or not chat %}
{# Models are listed in the administrator's order. Pinning is a
sidebar shortcut and deliberately does not reorder this. #}
<label class="model-select">
{% if current_model %}{{ model_avatar(current_model, cls="model-select__avatar") }}{% endif %}
<select class="select select--bare" name="model_id" id="model-select"
aria-label="Model"
{% if chat %}hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
hx-trigger="change"{% else %}onchange="
const u = new URL(window.location);
u.searchParams.set('model', this.value);
window.location = u;
"{% endif %}>
{% for model in models %}
<option value="{{ model.model_id }}"
{{ 'selected' if model.model_id == chat.model_id }}>{{ model.label }}</option>
{{ 'selected' if current_model and model.model_id == current_model.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>
</label>
{% elif current_model %}
<span class="badge">{{ current_model.label }}</span>
{% endif %}
{% endif %}
{% if chat and (can.get("chat.system_prompt") or can.get("chat.params")) %}
<button class="btn btn--icon" type="button" aria-label="Chat settings"
title="Chat settings"
onclick="document.getElementById('chat-settings').toggleAttribute('hidden')">
title="Chat settings" data-toggle="#chat-settings">
{{ icon("sliders") }}
</button>
{% else %}
<h1 class="topbar__title">Chats</h1>
{% endif %}
</div>
</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">
{# Collapsed by default: per-chat overrides, not everyday controls. Each
field saves on change, so there is no half-applied state. #}
<section class="panel" id="chat-settings" hidden>
<div class="panel__inner">
{% if current_model and current_model.description %}
<p class="chat-settings__note">{{ current_model.description }}</p>
<p class="panel__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."
placeholder="{{ inherited_prompt or '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>
<p class="field__hint">
{% if inherited_prompt %}
Leave empty to use the {{ inherited_from }} prompt shown above.
{% else %}
Overrides the model and instance prompts for this chat only.
{% endif %}
</p>
</div>
{% endif %}
{% if can.get("chat.params") %}
<div class="chat-settings__params">
<div class="grid grid--3">
<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', '') }}"
value="{{ chat.params_json.get('temperature') if chat.params_json.get('temperature') is not none else '' }}"
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', '') }}"
value="{{ chat.params_json.get('top_p') if chat.params_json.get('top_p') is not none else '' }}"
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', '') }}"
value="{{ chat.params_json.get('max_tokens') if chat.params_json.get('max_tokens') is not none else '' }}"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
</div>
</div>
@@ -110,22 +120,8 @@
</section>
{% endif %}
{% if not chat %}
{# No chat selected. #}
<div class="empty">
{{ mark(cls="empty__mark", uid="empty") }}
<h2 class="empty__title">The road goes ever on</h2>
<p class="empty__text">
Pick a chat from the side, or start a new one.
</p>
<button class="btn btn--primary" hx-post="/api/chats" hx-swap="none">
{{ icon("plus", "icon--sm") }} New chat
</button>
</div>
{% elif not models %}
{# Nothing to talk to yet. This is the state every fresh install lands in,
so it points straight at the fix rather than just reporting a problem. #}
{% if not models %}
{# Every fresh install lands here, so it points at the fix. #}
<div class="empty">
{{ icon("server", "empty__mark") }}
<h2 class="empty__title">No models available</h2>
@@ -164,71 +160,7 @@
</div>
</div>
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}>
{% if can.get("files.upload") %}
{# Uploads go up as soon as a file is chosen, so the chip (and any
rejection) appears immediately rather than at send time. The chips
carry hidden inputs, which is how the ids reach the message POST. #}
<form id="upload-form" hx-post="/api/files?chat_id={{ chat.id }}"
hx-target="#attachments" hx-swap="beforeend"
hx-encoding="multipart/form-data"
hx-on::after-request="this.reset()">
<input class="visually-hidden" type="file" name="file" id="file-input"
multiple accept="image/*,.pdf,.txt,.md,.csv,.json,.py,.js,.ts,.rs,.go,.sh,.sql,.yaml,.yml,.toml,.log"
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
</form>
{% endif %}
<div class="composer__attachments" id="attachments"></div>
<form class="composer__form"
hx-post="/api/chats/{{ chat.id }}/messages"
hx-target="#thread" hx-swap="beforeend"
hx-on::after-request="if (event.detail.successful) {
this.reset();
document.getElementById('attachments').replaceChildren();
const t = this.querySelector('textarea');
window.lembas.autosize(t);
window.lembas.scrollThread(true);
}">
{# The chips live outside this form, so their hidden inputs are pulled
in explicitly at submit time. #}
<div hx-include="#attachments" hidden></div>
{% if can.get("files.upload") %}
<button class="btn btn--icon composer__attach" type="button"
aria-label="Attach a file" title="Attach a file"
onclick="document.getElementById('file-input').click()">
{{ icon("attach") }}
</button>
{% endif %}
<textarea class="composer__input" name="content" rows="1"
data-autosize data-max-height="320" data-composer-input
placeholder="Send a message…" aria-label="Message"></textarea>
<button class="btn btn--primary composer__send" type="submit" aria-label="Send">
{{ icon("send", "icon--sm") }}
</button>
</form>
<p class="composer__hint">
Enter to send, Shift+Enter for a new line.
{% if can.get("files.upload") %}
Drag files in, or paste an image.
{% if current_model and not current_model.capabilities_json.get("vision") %}
<strong>{{ current_model.label }} has no vision</strong>, so images
will not be sent — documents still will.
{% endif %}
{% endif %}
</p>
{% if can.get("files.upload") %}
<div class="dropzone-overlay" aria-hidden="true">
{{ icon("attach", "icon--lg") }}
<span>Drop to attach</span>
</div>
{% endif %}
</div>
{% include "chat/_composer.html" %}
{% endif %}
</main>
</div>
+26 -15
View File
@@ -1,24 +1,22 @@
{% from "_macros.html" import icon, brand %}
{% from "_macros.html" import icon, brand, model_avatar %}
{#
Sidebar: brand, new chat, the folder tree, then unfiled chats.
Sidebar: brand, new chat, pinned models, the folder tree, then unfiled chats.
Folders render recursively through _folder.html. Chats appear under their
folder, and any chat without one falls to the flat list at the bottom.
"New chat" is a link, not a button that creates a row. The chat is written
when the first message is sent, so opening one and walking away leaves
nothing behind.
#}
<aside class="sidebar" id="sidebar" x-data="{ }">
<aside class="sidebar" id="sidebar">
<div class="sidebar__header">
{{ 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">
<a class="btn btn--primary btn--grow" href="/chat">
{{ icon("plus", "icon--sm") }} New chat
</button>
</a>
{% endif %}
{% if can.get("folder.manage") %}
<button class="btn btn--icon" hx-post="/api/folders" hx-swap="none"
@@ -30,6 +28,20 @@
{% endif %}
<nav class="sidebar__scroll" id="sidebar-tree" aria-label="Chats">
{% if pinned_models and can.get("chat.create") %}
{# Shortcuts to start a chat with a particular model. These link rather than
post, so no chat exists until something is actually said. #}
<div class="nav-group">
<div class="nav-group__label">Pinned models</div>
{% for model in pinned_models %}
<a class="nav-item nav-item--model" href="/chat?model={{ model.model_id }}">
{{ model_avatar(model, cls="nav-item__avatar") }}
<span class="nav-item__label">{{ model.label }}</span>
</a>
{% endfor %}
</div>
{% endif %}
{% if folders %}
<div class="nav-group">
<div class="nav-group__label">Folders</div>
@@ -52,14 +64,13 @@
</nav>
<div class="sidebar__footer">
<div class="row row--between">
<a class="nav-item" href="/settings" style="flex: 1">
<a class="nav-item" href="/settings">
{{ icon("user", "icon--sm") }}
<span class="nav-item__label">{{ user.name }}</span>
{% if user.is_admin %}<span class="badge badge--gold">admin</span>{% endif %}
</a>
</div>
<div class="row" style="gap: var(--sp-1); margin-top: var(--sp-1)">
<div class="sidebar__tools">
{% if user.is_admin %}
<a class="btn btn--icon" href="/admin" aria-label="Admin settings" title="Admin settings">
{{ icon("shield") }}
@@ -70,7 +81,7 @@
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
</button>
<span class="topbar__spacer"></span>
<span class="spacer"></span>
<form method="post" action="/auth/logout">
<button class="btn btn--icon" type="submit" aria-label="Sign out" title="Sign out">
{{ icon("logout") }}
+118 -55
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% from "_macros.html" import icon %}
{% from "_macros.html" import icon, model_avatar %}
{% block title %}Your settings - LLeMbas{% endblock %}
@@ -19,8 +19,28 @@
<h1 class="topbar__title">Your settings</h1>
</header>
<div class="admin-scroll">
<div class="admin-page">
{#
Tabs are radio inputs plus sibling selectors: no JavaScript, and the
chosen tab survives a re-render because the browser keeps the checked
state. Each panel is a real fragment of the page, not a fetch.
#}
<div class="tabs">
<div class="tabs__bar" role="tablist">
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-account" checked>
<label class="tabs__tab" for="tab-account">{{ icon("user", "icon--sm") }} Account</label>
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-models">
<label class="tabs__tab" for="tab-models">{{ icon("sliders", "icon--sm") }} Models</label>
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-appearance">
<label class="tabs__tab" for="tab-appearance">{{ icon("sun", "icon--sm") }} Appearance</label>
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-security">
<label class="tabs__tab" for="tab-security">{{ icon("key", "icon--sm") }} Security</label>
</div>
<div class="tabs__body">
<div class="page">
{% if error %}
<div class="alert alert--error" role="alert">
{{ icon("warning", "alert__icon") }} <span>{{ error }}</span>
@@ -32,26 +52,55 @@
</div>
{% endif %}
<section class="card">
{# --- Account --- #}
<section class="tabs__panel" data-tab="tab-account">
<div class="card">
<h2 class="card__title">Account</h2>
<div class="field">
<span class="field__label">Name</span>
<p>{{ user.name }}</p>
<dl class="detail-list">
<dt>Name</dt><dd>{{ user.name }}</dd>
<dt>Email</dt><dd class="mono">{{ user.email }}</dd>
<dt>Role</dt>
<dd><span class="badge {{ 'badge--gold' if user.is_admin }}">{{ user.role }}</span></dd>
<dt>Groups</dt>
<dd>
{% if user.groups %}
{% for group in user.groups %}<span class="badge">{{ group.name }}</span> {% endfor %}
{% else %}
<span class="faint">None</span>
{% endif %}
</dd>
</dl>
</div>
<div class="field">
<span class="field__label">Email</span>
<p class="mono text-sm">{{ user.email }}</p>
</div>
<div class="field">
<span class="field__label">Role</span>
<p>
<span class="badge {{ 'badge--gold' if user.is_admin }}">{{ user.role }}</span>
<div class="card">
<h2 class="card__title">What you can do</h2>
<p class="card__lede">
{% if user.is_admin %}
You are an administrator, so every permission applies.
{% else %}
From the instance baseline plus whatever your groups add.
{% endif %}
</p>
<ul class="perm-list">
{% for key, granted in can.items() %}
<li class="perm-list__item">
<span class="perm-list__state {{ 'is-on' if granted }}">
{{ icon("check" if granted else "x", "icon--sm") }}
</span>
<code>{{ key }}</code>
</li>
{% endfor %}
</ul>
</div>
</section>
<section class="card">
{# --- Models --- #}
<section class="tabs__panel" data-tab="tab-models">
<div class="card">
<h2 class="card__title">Default model</h2>
<p class="card__lede">
What a new chat starts with. Existing chats keep their model.
</p>
{% if models %}
<form method="post" action="/api/preferences/default-model">
<div class="field">
@@ -60,66 +109,81 @@
{% 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 %}
{{ model.label }}
</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>
{% if models %}
<div class="card">
<h2 class="card__title">Available to you</h2>
<p class="card__lede">In the order an administrator arranged them.</p>
<ul class="model-list">
{% for model in models %}
<li class="model-list__item">
<div class="row" style="gap: var(--sp-2); min-width: 0">
{{ model_avatar(model, cls="nav-item__avatar") }}
<div style="min-width: 0">
<strong>{{ model.label }}</strong>
{% if model.description %}
<div class="text-xs faint">{{ model.description }}</div>
{% endif %}
</div>
</div>
<div class="btn-row">
{% for name, on in (model.capabilities_json or {}).items() %}
{% if on %}<span class="badge badge--gold">{{ name }}</span>{% endif %}
{% endfor %}
{% if model.pinned %}<span class="badge">pinned</span>{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</section>
<section class="card">
<h2 class="card__title">Appearance</h2>
<div class="row" style="gap: var(--sp-3)">
{# --- Appearance --- #}
<section class="tabs__panel" data-tab="tab-appearance">
<div class="card">
<h2 class="card__title">Theme</h2>
<p class="card__lede">
Saved to this browser and to your account, so it follows you.
</p>
<div class="btn-row">
<button class="btn" type="button" onclick="window.lembas.applyTheme('moria')">
{{ icon("moon", "icon--sm") }} Moria
{{ icon("moon", "icon--sm") }} Moria — dark
</button>
<button class="btn" type="button" onclick="window.lembas.applyTheme('shire')">
{{ icon("sun", "icon--sm") }} Shire
{{ icon("sun", "icon--sm") }} Shire — light
</button>
</div>
<p class="field__hint" style="margin-top: var(--sp-3)">
Moria is the dark theme, Shire the light one. Your choice is saved
to this browser and to your account.
</p>
</div>
</section>
<section class="card">
{# --- Security --- #}
<section class="tabs__panel" data-tab="tab-security">
<div class="card">
<h2 class="card__title">Change password</h2>
<p class="card__lede">
Every other session is signed out when the password changes.
You stay signed in here.
</p>
<form method="post" action="/api/preferences/password">
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="current-password">Current password</label>
<input class="input" type="password" id="current-password"
name="current_password" required autocomplete="current-password">
</div>
<div class="field"></div>
<div class="field">
<label class="field__label" for="new-password">New password</label>
<input class="input" type="password" id="new-password" name="new_password"
@@ -132,24 +196,23 @@
name="confirm_password" required minlength="8"
autocomplete="new-password">
</div>
</div>
<button class="btn btn--primary" type="submit">Change password</button>
<p class="field__hint" style="margin-top: var(--sp-3)">
Every other session is signed out when the password changes. You
stay signed in here.
</p>
</form>
</section>
</div>
<section class="card">
<div class="card">
<h2 class="card__title">Session</h2>
<form method="post" action="/auth/logout">
<button class="btn btn--danger" type="submit">
{{ icon("logout", "icon--sm") }} Sign out
</button>
</form>
</div>
</section>
</div>
</div>
</div>
</main>
</div>
{% endblock %}
+37
View File
@@ -83,6 +83,43 @@ def registered(client: TestClient) -> dict[str, str]:
return credentials
@pytest.fixture
def make_chat(db: Session):
"""Create a chat row directly, as scaffolding for other tests.
Chats are normally created by POST /api/chats/start along with their first
exchange -- there is deliberately no endpoint that makes an empty one. Most
tests want a chat to act on, not that flow, so they get one straight from
the database rather than having to subtract an opening turn from every
assertion. The flow itself is covered in test_chat.py.
"""
from sqlalchemy import select
from lembas.db.models import Chat, Model, User
def _create(email: str | None = None, model_id: str | None = None) -> str:
user = (
db.scalar(select(User).where(User.email == email))
if email
else db.scalars(select(User).order_by(User.created_at)).first()
)
model = (
db.scalar(select(Model).where(Model.model_id == model_id))
if model_id
else db.scalars(select(Model).order_by(Model.position)).first()
)
chat = Chat(
user_id=user.id,
model_id=model.model_id if model else "",
connection_id=model.connection_id if model else None,
)
db.add(chat)
db.commit()
return chat.id
return _create
@pytest.fixture
def user_id(db: Session, registered: dict[str, str]) -> str:
"""The registered user's id.
+3 -1
View File
@@ -101,7 +101,9 @@ def test_htmx_requests_get_a_redirect_header_not_a_login_page(
):
"""An htmx request must never swap a login form into a fragment of the UI."""
client.post("/auth/logout", follow_redirects=False)
response = client.post("/api/chats", headers={"HX-Request": "true"})
response = client.post(
"/api/chats/start", data={"content": "hi"}, headers={"HX-Request": "true"}
)
assert response.status_code == 204
assert response.headers["HX-Redirect"] == "/auth/login"
+59 -18
View File
@@ -144,22 +144,61 @@ def _add_connection(db) -> Connection:
return connection
def test_new_chat_redirects_to_its_own_url(client: TestClient, db, registered):
# --- Starting a chat ---------------------------------------------------------
def test_starting_a_chat_creates_it_and_redirects(client: TestClient, db, registered):
_add_connection(db)
response = client.post("/api/chats", headers={"HX-Request": "true"})
response = client.post("/api/chats/start", data={"content": "Hello there"})
assert response.status_code == 204
assert response.headers["HX-Redirect"].startswith("/chat/")
chat = db.scalar(select(Chat))
assert chat.model_id == "test-model"
messages = db.scalars(select(Message).order_by(Message.created_at)).all()
assert [m.role for m in messages] == ["user", "assistant"]
assert messages[0].content == "Hello there"
def test_new_chat_picks_up_the_default_model(client: TestClient, db, registered):
def test_starting_with_nothing_creates_no_chat(client: TestClient, db, registered):
"""The whole point of lazy creation: an abandoned composer leaves nothing."""
_add_connection(db)
client.post("/api/chats", headers={"HX-Request": "true"})
assert client.post("/api/chats/start", data={"content": " "}).status_code == 204
assert db.scalar(select(Chat)) is None
def test_visiting_the_chat_page_creates_nothing(client: TestClient, db, registered):
_add_connection(db)
assert client.get("/chat").status_code == 200
assert db.scalar(select(Chat)) is None
def test_starting_a_chat_honours_the_requested_model(client: TestClient, db, registered):
"""The pinned-model shortcuts pass ?model=, which arrives here."""
connection = _add_connection(db)
db.add(Model(connection_id=connection.id, model_id="other-model", position=5))
db.commit()
client.post("/api/chats/start", data={"content": "hi", "model_id": "other-model"})
assert db.scalar(select(Chat)).model_id == "other-model"
def test_starting_a_chat_ignores_a_model_you_cannot_reach(client: TestClient, db, registered):
connection = _add_connection(db)
db.add(Model(connection_id=connection.id, model_id="secret", position=5, public=False))
db.commit()
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,
)
client.post("/api/chats/start", data={"content": "hi", "model_id": "secret"})
assert db.scalar(select(Chat)).model_id == "test-model"
def test_posting_a_message_stores_both_turns(client: TestClient, db, registered):
def test_posting_a_message_stores_both_turns(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
chat_id = make_chat()
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello there"})
assert response.status_code == 200
@@ -174,16 +213,18 @@ def test_posting_a_message_stores_both_turns(client: TestClient, db, registered)
assert "sse-connect" in response.text
def test_empty_message_is_ignored(client: TestClient, db, registered):
def test_empty_message_is_ignored(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
chat_id = make_chat()
assert client.post(f"/api/chats/{chat_id}/messages", data={"content": " "}).status_code == 204
assert db.scalar(select(Message)) is None
def test_a_chat_belonging_to_someone_else_is_not_found(client: TestClient, db, registered):
def test_a_chat_belonging_to_someone_else_is_not_found(
client: TestClient, db, registered, make_chat
):
_add_connection(db)
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
chat_id = make_chat()
client.post("/auth/logout", follow_redirects=False)
client.post(
@@ -195,9 +236,9 @@ def test_a_chat_belonging_to_someone_else_is_not_found(client: TestClient, db, r
assert client.get(f"/chat/{chat_id}").status_code == 404
def test_renaming_a_chat_stops_it_being_auto_titled(client: TestClient, db, registered):
def test_renaming_a_chat_stops_it_being_auto_titled(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
chat_id = make_chat()
client.patch(f"/api/chats/{chat_id}", data={"title": "My own title"})
chat = db.get(Chat, chat_id)
@@ -206,9 +247,9 @@ def test_renaming_a_chat_stops_it_being_auto_titled(client: TestClient, db, regi
assert chat.title_generated is True
def test_deleting_a_chat_removes_its_messages(client: TestClient, db, registered):
def test_deleting_a_chat_removes_its_messages(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
chat_id = make_chat()
client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello"})
client.delete(f"/api/chats/{chat_id}")
@@ -216,13 +257,13 @@ def test_deleting_a_chat_removes_its_messages(client: TestClient, db, registered
assert db.scalar(select(Message)) is None
def test_deleting_a_folder_keeps_the_chats_inside_it(client: TestClient, db, registered):
def test_deleting_a_folder_keeps_the_chats_inside_it(client: TestClient, db, registered, make_chat):
"""Losing a conversation to a mis-clicked folder delete is unforgivable."""
_add_connection(db)
client.post("/api/folders", data={"name": "Quests"})
folder = db.scalar(select(Folder))
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
chat_id = make_chat()
client.patch(f"/api/chats/{chat_id}", data={"folder_id": folder.id})
client.delete(f"/api/folders/{folder.id}")
@@ -296,10 +337,10 @@ def test_system_prompt_leads_the_message_list(db, user_id):
def test_streaming_reports_an_unreachable_endpoint_in_the_thread(
client: TestClient, db, registered
):
, make_chat):
"""A failed turn must never be an unexplained blank bubble."""
_add_connection(db) # points at 127.0.0.1:1, which refuses connections
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
chat_id = make_chat()
client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello"})
message = db.scalar(select(Message).where(Message.role == "assistant"))
+6 -5
View File
@@ -83,7 +83,7 @@ def pdf_bytes(pages: list[str]) -> bytes:
@pytest.fixture
def chat_with_model(client: TestClient, db, registered):
def chat_with_model(client: TestClient, db, registered, make_chat):
"""A chat whose model has vision turned on."""
connection = Connection(name="T", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
db.add(connection)
@@ -96,8 +96,7 @@ def chat_with_model(client: TestClient, db, registered):
)
)
db.commit()
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
return chat_id
return make_chat()
# --- Type detection and processing -------------------------------------------
@@ -326,7 +325,9 @@ def test_a_truly_empty_message_is_still_ignored(client: TestClient, db, chat_wit
assert db.scalar(select(Message)) is None
def test_you_cannot_attach_someone_elses_file(client: TestClient, db, chat_with_model):
def test_you_cannot_attach_someone_elses_file(
client: TestClient, db, chat_with_model, make_chat
):
"""A forged id must not pull another user's file into a conversation."""
client.post("/api/files", files={"file": ("mine.txt", b"secret", "text/plain")})
stolen = db.scalar(select(Attachment))
@@ -337,7 +338,7 @@ def test_you_cannot_attach_someone_elses_file(client: TestClient, db, chat_with_
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
their_chat = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
their_chat = make_chat(email="sam@shire.test")
client.post(
f"/api/chats/{their_chat}/messages",
data={"content": "gimme", "file_ids": [stolen.id]},
+135 -19
View File
@@ -99,7 +99,7 @@ def test_a_group_can_grant_back_what_the_baseline_removed(db, plain_user):
# --- 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 client.post("/api/chats/start", data={"content": "hi"}).status_code == 403
assert db.scalar(select(Chat)) is None
@@ -108,19 +108,21 @@ def test_folder_routes_are_refused_without_permission(client: TestClient, db, pl
assert client.post("/api/folders", data={"name": "Nope"}).status_code == 403
def test_changing_sampling_is_refused_without_permission(client: TestClient, db, plain_user):
def test_changing_sampling_is_refused_without_permission(
client: TestClient, db, plain_user, make_chat
):
_model(db, "test-model")
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
chat_id = make_chat(email="sam@shire.test")
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):
def test_sampling_is_allowed_once_a_group_grants_it(client: TestClient, db, plain_user, make_chat):
_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]
chat_id = make_chat(email="sam@shire.test")
assert client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.9"}).status_code == 204
chat = db.get(Chat, chat_id)
@@ -134,10 +136,10 @@ def test_sampling_is_allowed_once_a_group_grants_it(client: TestClient, db, plai
)
def test_out_of_range_parameters_are_dropped_not_clamped(
client: TestClient, db, registered, field, value
):
, make_chat):
"""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]
chat_id = make_chat()
client.patch(f"/api/chats/{chat_id}", data={field: value})
chat = db.get(Chat, chat_id)
@@ -145,9 +147,9 @@ def test_out_of_range_parameters_are_dropped_not_clamped(
assert field not in (chat.params_json or {})
def test_an_empty_parameter_clears_it(client: TestClient, db, registered):
def test_an_empty_parameter_clears_it(client: TestClient, db, registered, make_chat):
_model(db, "test-model")
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
chat_id = make_chat()
client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.7"})
client.patch(f"/api/chats/{chat_id}", data={"temperature": ""})
@@ -187,12 +189,14 @@ def test_disabled_models_are_hidden_from_everyone(db, registered):
assert permissions.models_visible_to(db, admin_user) == []
def test_switching_to_an_inaccessible_model_is_refused(client: TestClient, db, plain_user):
def test_switching_to_an_inaccessible_model_is_refused(
client: TestClient, db, plain_user, make_chat
):
"""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]
chat_id = make_chat(email="sam@shire.test")
response = client.patch(f"/api/chats/{chat_id}", data={"model_id": "secret-model"})
assert response.status_code == 403
@@ -201,17 +205,21 @@ def test_switching_to_an_inaccessible_model_is_refused(client: TestClient, db, p
assert chat.model_id == "open-model"
def test_model_select_permission_is_required_to_switch(client: TestClient, db, plain_user):
def test_model_select_permission_is_required_to_switch(
client: TestClient, db, plain_user, make_chat
):
_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]
chat_id = make_chat(email="sam@shire.test")
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):
def test_pinning_does_not_reorder_the_picker(db, registered):
"""Pinning is a sidebar shortcut. A picker whose order silently differs from
the admin screen is just confusing."""
connection = _connection(db)
db.add_all(
[
@@ -222,8 +230,8 @@ def test_pinned_models_sort_first(db, registered):
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",
"favourite",
]
@@ -254,7 +262,7 @@ def test_instance_default_model_is_used_for_new_chats(client: TestClient, db, re
db.commit()
settings_store.update(db, {"default_model": "chosen"})
client.post("/api/chats")
client.post("/api/chats/start", data={"content": "hi"})
assert db.scalar(select(Chat)).model_id == "chosen"
@@ -270,7 +278,7 @@ def test_a_users_own_default_beats_the_instance_default(client: TestClient, db,
settings_store.update(db, {"default_model": "instance-pick"})
client.post("/api/preferences/default-model", data={"model_id": "my-pick"})
client.post("/api/chats")
client.post("/api/chats/start", data={"content": "hi"})
assert db.scalar(select(Chat)).model_id == "my-pick"
@@ -346,7 +354,7 @@ def test_deactivating_a_user_revokes_their_sessions(client: TestClient, db, regi
assert other.get("/chat", follow_redirects=False).status_code == 303
def test_a_pinned_model_appears_once_in_the_picker(client: TestClient, db, registered):
def test_a_pinned_model_appears_once_in_the_picker(client: TestClient, db, registered, make_chat):
"""Two options with the same value, both selected, is not a picker."""
connection = _connection(db)
db.add_all(
@@ -357,7 +365,115 @@ def test_a_pinned_model_appears_once_in_the_picker(client: TestClient, db, regis
)
db.commit()
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
chat_id = make_chat()
page = client.get(f"/chat/{chat_id}").text
assert page.count('<option value="favourite"') == 1
assert page.count('<option value="ordinary"') == 1
# --- System prompt layering --------------------------------------------------
def test_system_prompt_falls_back_to_the_instance(db, user_id):
connection = _connection(db)
db.add(Model(connection_id=connection.id, model_id="m"))
db.commit()
settings_store.update(db, {"system_prompt": "You are terse."})
chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
db.add(chat)
db.commit()
assert chat_service.effective_system_prompt(db, chat) == "You are terse."
def test_a_models_prompt_beats_the_instance(db, user_id):
connection = _connection(db)
db.add(Model(connection_id=connection.id, model_id="m", system_prompt="You are a poet."))
db.commit()
settings_store.update(db, {"system_prompt": "You are terse."})
chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
db.add(chat)
db.commit()
assert chat_service.effective_system_prompt(db, chat) == "You are a poet."
def test_a_chats_prompt_beats_everything(db, user_id):
connection = _connection(db)
db.add(Model(connection_id=connection.id, model_id="m", system_prompt="You are a poet."))
db.commit()
settings_store.update(db, {"system_prompt": "You are terse."})
chat = Chat(
user_id=user_id, model_id="m", connection_id=connection.id,
system_prompt="You are a dwarf.",
)
db.add(chat)
db.commit()
assert chat_service.effective_system_prompt(db, chat) == "You are a dwarf."
def test_layers_replace_rather_than_stack(db, user_id):
"""Concatenating them reads well in a settings screen and badly in practice:
two layers that disagree give the model contradictory instructions."""
connection = _connection(db)
db.add(Model(connection_id=connection.id, model_id="m", system_prompt="MODEL"))
db.commit()
settings_store.update(db, {"system_prompt": "INSTANCE"})
chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
db.add(chat)
db.commit()
assert "INSTANCE" not in chat_service.effective_system_prompt(db, chat)
def test_no_prompt_anywhere_sends_no_system_message(db, user_id):
connection = _connection(db)
db.add(Model(connection_id=connection.id, model_id="m"))
db.commit()
chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
db.add(chat)
db.commit()
assert chat_service.build_request(db, chat)["messages"] == []
# --- Admin bulk actions ------------------------------------------------------
def test_bulk_disable_works(client: TestClient, db, registered):
"""Regression: /admin/models/{model_id} was registered first, so "bulk" was
parsed as a model id and every bulk action 404'd."""
connection = _connection(db)
db.add_all(
[
Model(connection_id=connection.id, model_id="a"),
Model(connection_id=connection.id, model_id="b"),
]
)
db.commit()
ids = [m.id for m in db.scalars(select(Model))]
response = client.post(
"/admin/models/bulk", data={"action": "disable", "model_ids": ids},
follow_redirects=False,
)
assert response.status_code == 303
assert all(not m.enabled for m in db.scalars(select(Model)))
def test_bulk_restrict_then_publish(client: TestClient, db, registered):
connection = _connection(db)
db.add(Model(connection_id=connection.id, model_id="a"))
db.commit()
model = db.scalar(select(Model))
client.post("/admin/models/bulk", data={"action": "private", "model_ids": [model.id]})
db.refresh(model)
assert model.public is False
client.post("/admin/models/bulk", data={"action": "public", "model_ids": [model.id]})
db.refresh(model)
assert model.public is True
def test_bulk_with_nothing_selected_is_harmless(client: TestClient, db, registered):
assert client.post(
"/admin/models/bulk", data={"action": "disable"}, follow_redirects=False
).status_code == 303