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>
422 lines
20 KiB
HTML
422 lines
20 KiB
HTML
{% 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 — 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 %}
|