Files
LLeMbas/src/lembas/web/templates/admin/model_detail.html
T
HomerandClaude Opus 5 a16510aba8 The interface speaks Slovak
969 strings, an instance default and a per-person choice, and no half-done corner:
the admin prose is translated too. Design and reasoning: LLeMbas.wiki/Translations.

KEYED BY THE ENGLISH SENTENCE

A missing entry renders the key, which is the English -- so an untranslated string
looks as it always did, an English instance is byte-for-byte 1.6.0, and a
half-finished catalogue is a half-translated page rather than a page of dotted key
names. The cost is that editing an English sentence orphans its translation
silently, which is what tests/test_translations.py asserts in both directions.

No gettext: .po -> .mo is a build step and this project does not have one.

A JINJA GLOBAL, AND THEREFORE A CONTEXTVAR

`t()` is a global for the reason `brand` already documents -- render() is bypassed
by 25 TemplateResponse calls and 8 get_template().render() calls, the latter being
the SSE frames, which have no Request at all. A global is bound once at import and
the language is per person, so the active language is a ContextVar set per request.

🚨 `get_current_user` had to become `async def`. FastAPI runs a sync dependency in a
threadpool, and anyio copies the context in and discards it on the way out -- so
the language was set where nothing could see it and every page rendered in the
instance's language whatever anybody had chosen, with no error anywhere.

NOT TRANSLATED, ON PURPOSE

Everything a model reads: the 60 prompt fragments, and the dates in harness.py,
schedule/runner.py and schedule/compile.py. Only `i18n.stamp` is localised, and
only where a person reads it -- with the *format* translatable as well as the
words, because "26. septembra 2026" is a different pattern rather than the same one
with different words in it. A process locale is not an option: global, not
thread-safe, two people's pages at once.

A second fragment telling models to answer in the reader's language was written
during this work and removed: `core.style` has said it since long before, and
test_an_empty_override_turns_a_fragment_off caught the duplicate.

THE BULK PASS

1213 sites wrapped by a one-off script that only touched patterns it could not
misread. It got three wrong in a way that mattered -- `t('…')` inside `attr="…"`
where the sentence held an apostrophe, closing the Jinja string and 500ing two
pages whose partials no test renders. tests/test_translations.py now compiles all
110 templates. It also wrapped the product's own name, an SSH key header, a
keystroke hint and an example URL, all taken back out: a string is not
translatable just because it is a string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 14:46:33 +00:00

422 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon, model_avatar %}
{% set section = "models" %}
{% block title %}{{ model.label }} - Models - {{ brand.name }}{% endblock %}
{% block heading %}{{ model.label }}{% endblock %}
{% block admin_content %}
<nav class="crumbs">
<a href="/admin/models">{{ icon("chevron-right", "icon--sm crumbs__back") }} All models</a>
<span class="spacer"></span>
{# Walking the list without going back to it, which is what you want when
tidying up a freshly imported connection. #}
{% if previous %}
<a class="btn btn--sm" href="/admin/models/{{ previous.id }}/edit"
title="{{ previous.label }}">{{ icon("arrow-up", "icon--sm") }} Previous</a>
{% endif %}
<span class="text-xs faint">{{ position_of }} of {{ total }}</span>
{% if next %}
<a class="btn btn--sm" href="/admin/models/{{ next.id }}/edit"
title="{{ next.label }}">Next {{ icon("arrow-down", "icon--sm") }}</a>
{% endif %}
</nav>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
<section class="card">
<div class="card__header">
<div class="row" style="gap: var(--sp-3); min-width: 0">
{{ model_avatar(model, cls="model-detail__avatar") }}
<div style="min-width: 0">
<strong>{{ model.label }}</strong>
<div><code class="text-xs faint">{{ model.model_id }}</code></div>
<div class="text-xs faint">via {{ model.connection.name }}</div>
</div>
</div>
<div class="btn-row">
{% if model.model_id == default_model %}
<span class="badge badge--leaf">{{ icon("star", "icon--sm") }} default model</span>
{% else %}
<form method="post" action="/admin/models/{{ model.id }}/default">
<button class="btn btn--sm" type="submit">
{{ icon("star", "icon--sm") }} Make default
</button>
</form>
{% endif %}
</div>
</div>
<div class="btn-row">
<form method="post" action="/admin/models/{{ model.id }}/image"
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="{{ t('Model image') }}">
<button class="btn btn--sm" type="submit">{{ icon("image", "icon--sm") }} Upload image</button>
</form>
{% if model.image_path %}
<form method="post" action="/admin/models/{{ model.id }}/image/delete">
<button class="btn btn--sm btn--danger" type="submit">{{ t("Remove image") }}</button>
</form>
{% endif %}
</div>
<p class="field__hint">{{ t("PNG, JPEG, WEBP or GIF, under 2 MB. Without one, the model gets a generated badge whose colour is derived from its id.") }}</p>
</section>
{# Empty, hidden, and outside every other form: the Detect button further down
is associated with it by `form="detect-efforts"`. It carries no fields on
purpose — detection asks the endpoint and needs nothing from this page. #}
<form id="detect-efforts" method="post"
action="/admin/models/{{ model.id }}/detect-efforts" hidden></form>
<form method="post" action="/admin/models/{{ model.id }}">
<section class="card">
<h2 class="card__title">{{ t("Presentation") }}</h2>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="display-name">{{ t("Display name") }}</label>
<input class="input" id="display-name" name="display_name"
value="{{ model.display_name }}" placeholder="{{ model.model_id }}">
<p class="field__hint">{{ t("Shown instead of the raw id. Empty uses the id.") }}</p>
</div>
<div class="field">
<label class="field__label" for="position">{{ t("Position") }}</label>
<input class="input" id="position" name="position" type="number" min="1"
max="{{ total }}" value="{{ position_of }}">
<p class="field__hint">
Place in the picker, 1–{{ total }}. Typing a number is the workable way
to move a model a long distance.
</p>
</div>
</div>
<div class="field">
<label class="field__label" for="context-length">{{ t("Context length") }}</label>
<input class="input" id="context-length" name="context_length" type="number"
min="0" step="1" placeholder="{{ t('unknown') }}"
value="{{ model.context_length or '' }}">
<p class="field__hint">{{ t("How many tokens this model can hold, filled in from the endpoint where it says. Leave it empty if you do not know: the context percentage and automatic compaction both stay off rather than working from a guess.") }}</p>
</div>
<div class="field">
<span class="field__label">{{ t("Reasoning efforts this model accepts") }}</span>
<div class="btn-row">
{% for value in efforts %}
<label class="checkbox">
<input type="checkbox" name="reasoning_efforts" value="{{ value }}"
{{ 'checked' if value in model_efforts }}>
<span class="mono">{{ value }}</span>
</label>
{% endfor %}
</div>
{% if detected %}
<div class="alert alert--{{ 'success' if detected == 'success' else 'warning' }}"
role="status">
{{ icon('sparkle' if detected == 'success' else 'warning', 'alert__icon') }}
<span>{{ detected_message }}</span>
</div>
{% endif %}
{#
Reading the answer rather than asking somebody to know it. llama-server
publishes the loaded model's Jinja chat template on `/props`, and that
template is the thing that rejects an effort it does not recognise --
so the accepted set is written down in the one authoritative place.
Endpoints without that route (OpenAI, vLLM) say so rather than
pretending the model accepts nothing.
Its own form, because this page's main form is a PUT of everything and
a detect must not carry half-edited fields with it — and that form is
declared before the main one rather than here, with this button reaching
it by id.
🚨 It was written inline here, nested inside the main form, which HTML
does not allow. Nothing complains: the parser *drops* the inner `form`
start tag and then lets the matching end tag close the outer one — so
from this point down the page was in no form at all. "Save changes"
submitted nothing; the description, the system prompt, every capability
and the whole availability card could not be saved. And this button
submitted the main form's surviving half to the *save* route, where every
field it did not carry took its default: description cleared, system
prompt cleared, and the model disabled with all of its tools off.
Shipped in 1.3.0 and found in 1.3.2 by asking a browser which form each
control belonged to, which is the only thing that finds it — the markup
reads correctly, and a test posting to the route bypasses the parser
entirely. `tests/test_form_structure.py` is the guard.
#}
<button class="btn btn--sm" type="submit" form="detect-efforts">
{{ icon('search', 'icon--sm') }} Detect from the endpoint
</button>
<p class="field__hint">
The vocabulary is <strong>{{ t("not the same for every model") }}</strong>, and
sending one a model does not know is not ignored — it is rendered into
the model's chat template, which raises and fails the whole reply.
gpt-oss takes <span class="mono">{{ t("low/medium/high") }}</span>; Bonsai takes
<span class="mono">{{ t("low/medium/xhigh") }}</span> and refuses
<span class="mono">{{ t("high") }}</span>; OpenAI has added
<span class="mono">{{ t("minimal") }}</span>, <span class="mono">{{ t("xhigh") }}</span> and
<span class="mono">{{ t("max") }}</span> at various points.
<br>
Tick none and the common three are offered, which is right for almost
everything. If an endpoint ever refuses one anyway, that reply is
retried without it and this list corrects itself — so this is worth
setting by hand only to save that one round trip.
</p>
</div>
<div class="field">
<label class="field__label" for="default-effort">{{ t("Default reasoning effort") }}</label>
<select class="select" id="default-effort" name="default_effort">
<option value="">{{ t("None — send nothing") }}</option>
{% for value in model_efforts %}
<option value="{{ value }}"
{{ 'selected' if model.params_json.get('reasoning_effort') == value }}>
{{ value }}
</option>
{% endfor %}
</select>
<p class="field__hint">
A <em>{{ t("seed") }}</em>, not a per-request setting: it is copied onto a chat when
the chat is created and when somebody switches to this model, and from
then on the chat's own value is what is sent. Changing it here therefore
does nothing to chats that already exist. The composer's picker shows
whichever level is actually in force, so what somebody sees there is
what goes out. Anyone can change it per chat with
<span class="mono">{{ t("/effort") }}</span>, and the control only appears on a
model marked <strong>{{ t("Reasoning") }}</strong> above.
<br>
Sent two ways at once, because there is no one field that works: OpenAI
and vLLM read <span class="mono">{{ t("reasoning_effort") }}</span>, while
llama.cpp drops it silently and reads only
<span class="mono">{{ t("chat_template_kwargs") }}</span> — which is the route by
which it reaches gpt-oss. Both go out, and only on a chat that has an
effort set, so an endpoint strict about unknown parameters is untouched
until somebody chooses one.
</p>
</div>
<div class="field">
<label class="field__label" for="description">{{ t("Description") }}</label>
<textarea class="textarea" id="description" name="description" rows="2"
placeholder="{{ t('What is this model good at?') }}">{{ model.description }}</textarea>
<p class="field__hint">
Shown in the chat settings panel and your users' settings &mdash; and, if any
model here may ask another one a question, read by those models too.
</p>
</div>
<div class="field">
<label class="field__label" for="notes">{{ t("Facts for other models") }}</label>
<textarea class="textarea" id="notes" name="notes" rows="3"
placeholder="{{ t('Parameters, quantisation, a benchmark figure, what it is bad at') }}">{{ model.notes }}</textarea>
<p class="field__hint">{{ t("Never shown to a person. It goes into the list of the other models that a model sees when it is allowed to ask one of them a question, so write what would help it choose: size, what this one is good and bad at, a score you trust. Leave it empty and the description above carries that on its own.") }}</p>
</div>
</section>
<section class="card">
<h2 class="card__title">{{ t("System prompt") }}</h2>
<p class="card__lede">{{ t("Used by chats on this model that have no prompt of their own. A chat's own prompt overrides this; this overrides the instance prompt.") }}</p>
<div class="field">
<label class="field__label visually-hidden" for="system-prompt">{{ t("System prompt") }}</label>
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="5"
placeholder="{{ instance_prompt or 'No instance prompt is set.' }}"
>{{ model.system_prompt }}</textarea>
<p class="field__hint">
{% if instance_prompt %}
Leave empty to fall back to the instance prompt shown above.
{% else %}
Leave empty to send no system prompt.
{% endif %}
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">{{ t("Capabilities") }}</h2>
<p class="card__lede">
What this endpoint can do. Endpoints rarely advertise it reliably, so it
is your call. <strong>{{ t("reasoning") }}</strong> shows the thinking block,
<strong>{{ t("vision") }}</strong> lets images be sent, and <strong>{{ t("tools") }}</strong> is
whether a tool list may be sent at all — turn it on for a model that does
not support tool calling and every one of its replies fails.
<strong>{{ t("embeddings") }}</strong> is the odd one out: it says this is not a chat
model at all, but one that turns text into vectors, and it is what
<a href="/admin/extraction">Extraction</a> picks from.
</p>
<div class="checkbox-row">
{% for name in capabilities %}
<label class="checkbox">
<input type="checkbox" name="capability" value="{{ name }}"
{{ 'checked' if (model.capabilities_json or {}).get(name) }}>
<span>{{ name }}</span>
</label>
{% endfor %}
</div>
</section>
<section class="card">
<h2 class="card__title">
Built-in tools
{% if not (model.capabilities_json or {}).get("tools") %}
<span class="badge">{{ t("needs tools") }}</span>
{% endif %}
</h2>
<p class="card__lede">
What this model is given, as opposed to what it is capable of. None of it
applies unless <strong>{{ t("tools") }}</strong> is ticked above. Each one is also
subject to the instance being configured for it and to the reader's own
permissions — this only decides whether it is offered to <em>{{ t("this") }}</em>
model.
</p>
<div class="checkbox-row">
{# A model configured before these existed has no tool_* keys. Ticked by
default when `tools` is on, matching what the tool registry does, so
the form shows what will actually happen rather than a row of empty
boxes that would take web search away on the next save. #}
{% for key, label in tool_capabilities %}
<label class="checkbox">
<input type="checkbox" name="capability" value="{{ key }}"
{{ 'checked' if (model.capabilities_json or {}).get(key, tool_default) }}>
<span>{{ label }}</span>
</label>
{% endfor %}
</div>
</section>
<section class="card">
<h2 class="card__title">{{ t("Availability") }}</h2>
<div class="field">
<div class="checkbox-row">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if model.enabled }}>
<span>{{ t("Enabled — offered in chats") }}</span>
</label>
<label class="checkbox">
<input type="checkbox" name="pinned" value="true" {{ 'checked' if model.pinned }}>
<span>{{ t("Pinned — shortcut in the chat sidebar") }}</span>
</label>
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="public" value="true" {{ 'checked' if model.public }}>
<span>{{ t("Available to everyone") }}</span>
</label>
<p class="field__hint">{{ t("Uncheck to restrict this model to chosen groups. Administrators always have access.") }}</p>
</div>
<div class="field">
<span class="field__label">{{ t("Groups with access") }}</span>
{% if groups %}
<div class="checkbox-row">
{% for group in groups %}
<label class="checkbox">
<input type="checkbox" name="group_ids" value="{{ group.id }}"
{{ 'checked' if group in model.groups }}>
<span>{{ group.name }}</span>
</label>
{% endfor %}
</div>
<p class="field__hint">{{ t("Ignored while the model is available to everyone.") }}</p>
{% else %}
<p class="field__hint">
No groups yet — <a href="/admin/groups">create one</a> to restrict access.
</p>
{% endif %}
</div>
</section>
<div class="btn-row">
<button class="btn btn--primary" type="submit">{{ t("Save changes") }}</button>
<a class="btn btn--ghost" href="/admin/models">Back to all models</a>
</div>
</form>
{# Outside the form above, and it has to be: two forms cannot nest, and this one
posts somewhere else. See the note beside the Detect button. #}
<section class="card">
<h2 class="card__title">{{ t("Default personality") }}</h2>
<p class="card__lede">
Who this model is before it has worked out who it is with somebody. Different
from the system prompt above: that is an instruction you write, this is a
character it can be — and, with <strong>{{ t("Edit its own personality") }}</strong>
ticked, one it rewrites for itself.
</p>
<p class="card__lede">
<strong>{{ t("A personality belongs to a person.") }}</strong> Each account gets its own
version of this model's character, starting from what you write here and
diverging from it the first time the model writes its own. Changing this
afterwards does not reach anybody who already has one, and it is not stacked
underneath theirs — two personalities at once would contradict each other and
nobody could tell which was losing. What the model *is*, as opposed to who it
has become with somebody, belongs in <strong>{{ t("Description") }}</strong> and
<strong>{{ t("Facts for other models") }}</strong> above, which are the same for everyone.
</p>
<form method="post" action="/admin/models/{{ model.id }}/persona">
<div class="field">
<label class="field__label visually-hidden" for="persona">{{ t("Default personality") }}</label>
<textarea class="textarea" id="persona" name="content" rows="6"
placeholder="{{ t('Nothing yet. Write one, or let the model write its own.') }}"
>{{ persona.content if persona else "" }}</textarea>
<p class="field__hint">
Up to {{ persona_limit }} characters, in the first person. Empty removes it
and its history. It is sent on every request, so length here costs the same
as length in the system prompt.
</p>
</div>
<div class="btn-row">
<button class="btn" type="submit">{{ t("Save personality") }}</button>
{% if persona and persona.author == "model" %}
<span class="badge badge--leaf">{{ t("last written by the model") }}</span>
{% elif persona %}
<span class="badge">{{ t("last written here") }}</span>
{% endif %}
</div>
</form>
</section>
{% if persona and persona.revisions %}
<section class="card">
<h2 class="card__title">
Earlier defaults <span class="badge">{{ persona.revisions|length }}</span>
</h2>
<p class="card__lede">{{ t("What this default said before each change. Each person's own personality keeps its own history, which they can see and restore in their own settings — this is the starting point's history, not theirs.") }}</p>
<ul class="model-list">
{% for revision in persona.revisions %}
<li class="model-list__item">
<div style="min-width: 0">
<strong>{{ revision.created_at.strftime("%Y-%m-%d %H:%M") }}</strong>
{% if revision.author == "model" %}
<span class="badge badge--leaf">{{ t("model") }}</span>
{% else %}
<span class="badge">{{ t("you") }}</span>
{% endif %}
{% if revision.note %}<div class="text-xs faint">{{ revision.note }}</div>{% endif %}
<div class="text-xs faint">
{{ revision.content[:200] }}{{ "…" if revision.content|length > 200 }}
</div>
</div>
<form method="post" action="/admin/models/{{ model.id }}/persona/revert"
data-confirm="{{ t('Put this version back? The current one is kept in the history.') }}"
data-confirm-label="{{ t('Restore') }}" data-confirm-danger="false">
<input type="hidden" name="revision_id" value="{{ revision.id }}">
<button class="btn btn--sm" type="submit">
{{ icon("refresh", "icon--sm") }} Restore
</button>
</form>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% endblock %}