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:
co-authored by
Claude Opus 4.8
parent
d90195015c
commit
29db54960e
@@ -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,135 +19,198 @@
|
||||
<h1 class="topbar__title">Your settings</h1>
|
||||
</header>
|
||||
|
||||
<div class="admin-scroll">
|
||||
<div class="admin-page">
|
||||
{% if error %}
|
||||
<div class="alert alert--error" role="alert">
|
||||
{{ icon("warning", "alert__icon") }} <span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if saved %}
|
||||
<div class="alert alert--success" role="status">
|
||||
{{ icon("check", "icon--sm") }} <span>{{ saved }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{#
|
||||
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>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Account</h2>
|
||||
<div class="field">
|
||||
<span class="field__label">Name</span>
|
||||
<p>{{ user.name }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field__label">Email</span>
|
||||
<p class="mono text-sm">{{ user.email }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field__label">Role</span>
|
||||
<p>
|
||||
<span class="badge {{ 'badge--gold' if user.is_admin }}">{{ user.role }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Default model</h2>
|
||||
{% if models %}
|
||||
<form method="post" action="/api/preferences/default-model">
|
||||
<div class="field">
|
||||
<select class="select" name="model_id" aria-label="Default model">
|
||||
<option value="">Use the instance default</option>
|
||||
{% for model in models %}
|
||||
<option value="{{ model.model_id }}"
|
||||
{{ 'selected' if model.model_id == user.settings_json.get('default_model') }}>
|
||||
{{ model.label }}{% if model.pinned %} — pinned{% endif %}
|
||||
</option>
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if saved %}
|
||||
<div class="alert alert--success" role="status">
|
||||
{{ icon("check", "icon--sm") }} <span>{{ saved }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Account --- #}
|
||||
<section class="tabs__panel" data-tab="tab-account">
|
||||
<div class="card">
|
||||
<h2 class="card__title">Account</h2>
|
||||
<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="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 %}
|
||||
</select>
|
||||
<p class="field__hint">What a new chat starts with. Existing chats keep their model.</p>
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
<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.
|
||||
{# --- 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">
|
||||
<select class="select" name="model_id" aria-label="Default model">
|
||||
<option value="">Use the instance default</option>
|
||||
{% for model in models %}
|
||||
<option value="{{ model.model_id }}"
|
||||
{{ 'selected' if model.model_id == user.settings_json.get('default_model') }}>
|
||||
{{ model.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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 %}
|
||||
</div>
|
||||
|
||||
{% 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 %}
|
||||
</p>
|
||||
<div class="checkbox-row">
|
||||
{% for key, granted in can.items() %}
|
||||
<span class="badge {{ 'badge--success' if granted }}">
|
||||
{{ key }}{{ '' if granted else ' — no' }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if user.groups %}
|
||||
<p class="field__hint" style="margin-top: var(--sp-3)">
|
||||
Groups: {% for group in user.groups %}{{ group.name }}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Appearance</h2>
|
||||
<div class="row" style="gap: var(--sp-3)">
|
||||
<button class="btn" type="button" onclick="window.lembas.applyTheme('moria')">
|
||||
{{ icon("moon", "icon--sm") }} Moria
|
||||
</button>
|
||||
<button class="btn" type="button" onclick="window.lembas.applyTheme('shire')">
|
||||
{{ icon("sun", "icon--sm") }} Shire
|
||||
</button>
|
||||
</div>
|
||||
<p class="field__hint" style="margin-top: var(--sp-3)">
|
||||
Moria is the dark theme, Shire the light one. Your choice is saved
|
||||
to this browser and to your account.
|
||||
</p>
|
||||
</section>
|
||||
{# --- 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 — dark
|
||||
</button>
|
||||
<button class="btn" type="button" onclick="window.lembas.applyTheme('shire')">
|
||||
{{ icon("sun", "icon--sm") }} Shire — light
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Change password</h2>
|
||||
<form method="post" action="/api/preferences/password">
|
||||
<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">
|
||||
{# --- 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"
|
||||
required minlength="8" autocomplete="new-password">
|
||||
<p class="field__hint">At least 8 characters.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="confirm-password">Confirm new password</label>
|
||||
<input class="input" type="password" id="confirm-password"
|
||||
name="confirm_password" required minlength="8"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn--primary" type="submit">Change password</button>
|
||||
</form>
|
||||
</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"
|
||||
required minlength="8" autocomplete="new-password">
|
||||
<p class="field__hint">At least 8 characters.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="confirm-password">Confirm new password</label>
|
||||
<input class="input" type="password" id="confirm-password"
|
||||
name="confirm_password" required minlength="8"
|
||||
autocomplete="new-password">
|
||||
</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>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Session</h2>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button class="btn btn--danger" type="submit">
|
||||
{{ icon("logout", "icon--sm") }} Sign out
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<div 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>
|
||||
|
||||
Reference in New Issue
Block a user