The brand accent was rune gold. It is now mallorn green: --gold becomes
--leaf in tokens.css and everywhere it resolved, and the mark's wafer is
the green of the leaves lembas travels wrapped in rather than the biscuit
inside. Yellow is left to mean exactly one thing in the interface -- a
warning -- instead of two.
Two knock-on choices the rename forced:
- Code keywords move from the brand accent to --warning. Strings are
--success, which is green; keywords in leaf green beside them is not
a colour scheme.
- --success itself leans teal now. Two greens a hue apart read as one
colour rendered badly, and an unread dot has to be tellable from a
brand badge at a glance.
The mark is redrawn, not just recoloured. The blade is ovate -- widest a
third up from the base, rounded where the stem meets it, drawn out only
at the tip -- because the old one was pointed at both ends and read as an
eye. It is also much larger relative to the tile: at 16px the silhouette
is all that survives, and a small leaf on a large tile is a green square
with a smudge on it. Veins sweep towards the tip and shorten as the blade
narrows. The score cross is thinner and fainter so it stays texture.
A single diagonal score was tried first and rejected: behind a diagonal
leaf it does not read as scoring, it reads as a line struck through the
mark.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
394 lines
17 KiB
HTML
394 lines
17 KiB
HTML
{% extends "base.html" %}
|
|
{% from "_macros.html" import icon, model_avatar %}
|
|
|
|
{% block title %}Your settings - LLeMbas{% endblock %}
|
|
|
|
{% block head %}
|
|
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
|
{% endblock %}
|
|
|
|
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
|
|
|
{% block body %}
|
|
<div class="shell">
|
|
{% include "partials/sidebar.html" %}
|
|
|
|
<main class="main">
|
|
<header class="topbar">
|
|
<h1 class="topbar__title">Your settings</h1>
|
|
</header>
|
|
|
|
{#
|
|
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>
|
|
|
|
{% if audio.stt_enabled or audio.tts_enabled %}
|
|
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-audio">
|
|
<label class="tabs__tab" for="tab-audio">{{ icon("speaker", "icon--sm") }} Audio</label>
|
|
{% endif %}
|
|
|
|
{% if can.get("library.use") %}
|
|
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-memory">
|
|
<label class="tabs__tab" for="tab-memory">{{ icon("sparkle", "icon--sm") }} Memory</label>
|
|
{% endif %}
|
|
|
|
<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--leaf' 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 %}
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
{# --- 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--leaf">{{ name }}</span>{% endif %}
|
|
{% endfor %}
|
|
{% if model.pinned %}<span class="badge">pinned</span>{% endif %}
|
|
</div>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
</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>
|
|
|
|
<div class="card">
|
|
<h2 class="card__title">Install as an app</h2>
|
|
<p class="card__lede">
|
|
Runs in its own window, without browser chrome. Everything still
|
|
comes from your server — there is no offline mode beyond a page
|
|
saying so.
|
|
</p>
|
|
{# Revealed by app.js only when the browser actually offers an
|
|
install. Firefox and desktop Safari never do, and a button that
|
|
does nothing is worse than no button. #}
|
|
<div class="btn-row" data-install-app hidden>
|
|
<button class="btn btn--primary" type="button"
|
|
onclick="window.lembas.promptInstall()">
|
|
{{ icon("plus", "icon--sm") }} Install
|
|
</button>
|
|
</div>
|
|
<p class="field__hint">
|
|
Only offered over HTTPS or on localhost, and not at all in some
|
|
browsers. On iOS, use Share → Add to Home Screen.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
{# --- Audio --- #}
|
|
{% if audio.stt_enabled or audio.tts_enabled %}
|
|
<section class="tabs__panel" data-tab="tab-audio">
|
|
<form method="post" action="/api/preferences/audio">
|
|
{% if audio.tts_enabled %}
|
|
<div class="card">
|
|
<h2 class="card__title">Reading replies aloud</h2>
|
|
<p class="card__lede">
|
|
Overrides what the administrator chose, for you only.
|
|
</p>
|
|
|
|
<div class="grid grid--2">
|
|
<div class="field">
|
|
<label class="field__label" for="audio-voice">Voice</label>
|
|
{# Options are fetched from the speech endpoint rather than
|
|
stored, so the list follows the server. #}
|
|
<select class="select" id="audio-voice" name="voice">
|
|
{% with selected = user_audio.get('voice', ''),
|
|
instance_voice = audio.tts_voice %}
|
|
{% include "partials/_voice_options.html" %}
|
|
{% endwith %}
|
|
</select>
|
|
<p class="field__hint">
|
|
{% if voice_error %}
|
|
The voice list could not be read: {{ voice_error }}
|
|
{% else %}
|
|
{{ voices | length }} available.
|
|
{% endif %}
|
|
</p>
|
|
</div>
|
|
<div class="field">
|
|
<label class="field__label" for="audio-speed">Speed</label>
|
|
<input class="input" id="audio-speed" name="speed" type="number"
|
|
min="0.25" max="4" step="0.05"
|
|
value="{{ user_audio.get('speed', '') }}"
|
|
placeholder="{{ audio.tts_speed }}">
|
|
<p class="field__hint">Leave empty to use the instance default.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label class="checkbox">
|
|
<input type="checkbox" name="autoplay" value="true"
|
|
{{ 'checked' if user_audio.get('autoplay', audio.tts_autoplay) }}>
|
|
<span>Read each reply aloud as it finishes</span>
|
|
</label>
|
|
<p class="field__hint">
|
|
Only replies that arrive while you are looking at the chat.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if audio.stt_enabled %}
|
|
<div class="card">
|
|
<h2 class="card__title">Dictation</h2>
|
|
<div class="field">
|
|
<label class="field__label" for="audio-language">Language</label>
|
|
<input class="input" id="audio-language" name="language" maxlength="16"
|
|
value="{{ user_audio.get('language', '') }}"
|
|
placeholder="{{ audio.stt_language or 'detect' }}">
|
|
<p class="field__hint">
|
|
An ISO code such as <code>en</code> or <code>sk</code>. Empty
|
|
lets the server work it out, which is usually best.
|
|
</p>
|
|
</div>
|
|
<p class="field__hint">
|
|
The microphone needs HTTPS or localhost — browsers do not grant
|
|
it over plain HTTP.
|
|
</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="btn-row">
|
|
<button class="btn btn--primary" type="submit">Save audio preferences</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
{% endif %}
|
|
|
|
{# --- Memory --- #}
|
|
{% if can.get("library.use") %}
|
|
<section class="tabs__panel" data-tab="tab-memory">
|
|
<div class="card">
|
|
<h2 class="card__title">
|
|
What models remember about you
|
|
<span class="badge">{{ memories|length }}</span>
|
|
</h2>
|
|
<p class="card__lede">
|
|
Short facts, shown to every model you talk to on every turn — so
|
|
they are few and short on purpose. Anything longer belongs in a
|
|
<a href="/library/notes">note</a>. Nothing here is shared with
|
|
anyone else, ever.
|
|
</p>
|
|
|
|
{% if memories %}
|
|
<ul class="model-list">
|
|
{% for memory in memories %}
|
|
<li class="model-list__item">
|
|
<form method="post" action="/api/library/memories/{{ memory.id }}"
|
|
class="row" style="flex: 1; gap: var(--sp-2); min-width: 0">
|
|
<input class="input" name="content" value="{{ memory.content }}"
|
|
maxlength="{{ memory_limit }}" style="flex: 1">
|
|
<button class="btn btn--sm" type="submit">Save</button>
|
|
<button class="btn btn--sm btn--danger" type="submit"
|
|
formaction="/api/library/memories/{{ memory.id }}/delete"
|
|
data-confirm-button="Forget this?"
|
|
data-confirm-title="Forget">
|
|
{{ icon("trash", "icon--sm") }}
|
|
</button>
|
|
</form>
|
|
{% if memory.author == "model" %}
|
|
<span class="badge badge--leaf">remembered</span>
|
|
{% endif %}
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% else %}
|
|
<p class="muted text-sm">
|
|
Nothing yet. A model with the memory tool adds to this when you
|
|
tell it something worth keeping.
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2 class="card__title">Add one</h2>
|
|
<form method="post" action="/api/library/memories" class="row"
|
|
style="gap: var(--sp-2)">
|
|
<input class="input" name="content" required style="flex: 1"
|
|
maxlength="{{ memory_limit }}"
|
|
placeholder="Prefers metric units and a 24-hour clock.">
|
|
<button class="btn btn--primary" type="submit">Remember</button>
|
|
</form>
|
|
<p class="field__hint">
|
|
One fact per record, at most {{ memory_limit }} characters. Never
|
|
put a password or a key here — it is sent to the model with every
|
|
message.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
{% endif %}
|
|
|
|
{# --- 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="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 %}
|