0bee366488
composer.js built its menu lazily inside show(), and refresh() wrote list.innerHTML before calling it. `list` is null until build() has run, so the first `/` or `@` ever typed threw a TypeError and took the handler with it. The menu has never appeared in any browser. That is why /compact "isn't there": nothing was. I shipped it having only run `node --check`, which parses the file happily. So this also brings the thing that catches it: a DOM stub driven under node -- not committed, hard rule 1 stands, it is an instrument like curl. It reproduced the crash in one run and immediately found two more: choosing a command from the menu left `/help` sitting in the box so the next Enter ran it again, and Tab completed nothing. Tab now completes and Enter runs, which is the split that matters for a command taking an argument. `.select--sm` was used three times and defined nowhere. I deleted the copy in chat.css and left a comment saying it "is defined once, in app.css", where it did not exist -- so those selects fell back to plain `.select`: width 100% in a flex row where four siblings wanted the same, all of them shrinking together until each was a few characters wide, and half a rem taller than everything beside them. That was the whole of "the connection switch needs to be wider". The connection and directory move to the topbar. They cannot change -- update_chat refuses both with a 409 -- so they are facts about the chat, of a kind with the Temporary badge, not controls on the message. The mode stays by the box. Compaction says it is working. It makes a model call that takes seconds and had no indicator anywhere: `hx-indicator` appears nowhere in this codebase, and the Generation.status channel that says "Summarising earlier messages…" for the automatic path cannot be borrowed, because it lives in the streaming bubble and this endpoint refuses to run while any message is unfinished. The overflow menu now runs the same code as /compact rather than posting for itself, so there is one implementation, one spinner, and one place the endpoint's four carefully written 409s finally reach somebody. /effort, low medium high, per chat with a per-model default. It goes out twice because there is no field that works everywhere: OpenAI and vLLM read reasoning_effort, llama.cpp's own docs say other values "have no effect" and its maintainer says the field "simply gets dropped without error or logging" -- what reaches gpt-oss behind it is chat_template_kwargs. Both are sent, and only once an effort has been chosen, so a provider strict about unknown parameters sees exactly the request it always did until somebody opts in. The control appears only on a model marked `reasoning`, a flag that has existed since the beginning with no reader at all. Mentions and recognised commands are marked as you type -- a mirror behind the textarea holding the same text with every character transparent, contributing nothing but a rounded rectangle, so a pixel of drift is a misplaced rectangle rather than a doubled glyph. A command is marked only when it resolves, so `/thoughts on this` visibly is not one before you send it. And again in the transcript, where user turns had no render step at all and now escape before they inject. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
265 lines
10 KiB
HTML
265 lines
10 KiB
HTML
{% extends "admin/_layout.html" %}
|
||
{% from "_macros.html" import icon, model_avatar %}
|
||
{% set section = "models" %}
|
||
|
||
{% block title %}{{ model.label }} - Models - LLeMbas{% 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="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">Remove image</button>
|
||
</form>
|
||
{% endif %}
|
||
</div>
|
||
<p class="field__hint">
|
||
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>
|
||
|
||
<form method="post" action="/admin/models/{{ model.id }}">
|
||
<section class="card">
|
||
<h2 class="card__title">Presentation</h2>
|
||
|
||
<div class="grid grid--2">
|
||
<div class="field">
|
||
<label class="field__label" for="display-name">Display name</label>
|
||
<input class="input" id="display-name" name="display_name"
|
||
value="{{ model.display_name }}" placeholder="{{ model.model_id }}">
|
||
<p class="field__hint">Shown instead of the raw id. Empty uses the id.</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="field__label" for="position">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">Context length</label>
|
||
<input class="input" id="context-length" name="context_length" type="number"
|
||
min="0" step="1" placeholder="unknown"
|
||
value="{{ model.context_length or '' }}">
|
||
<p class="field__hint">
|
||
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">
|
||
<label class="field__label" for="default-effort">Default reasoning effort</label>
|
||
<select class="select" id="default-effort" name="default_effort">
|
||
<option value="">Whatever the model does</option>
|
||
{% for value in efforts %}
|
||
<option value="{{ value }}"
|
||
{{ 'selected' if model.params_json.get('reasoning_effort') == value }}>
|
||
{{ value }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
<p class="field__hint">
|
||
Where new chats on this model start. Anyone can change it per chat with
|
||
<span class="mono">/effort</span>, and the control only appears on a
|
||
model marked <strong>Reasoning</strong> above.
|
||
<br>
|
||
Sent two ways at once, because there is no one field that works: OpenAI
|
||
and vLLM read <span class="mono">reasoning_effort</span>, while
|
||
llama.cpp drops it silently and reads only
|
||
<span class="mono">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">Description</label>
|
||
<textarea class="textarea" id="description" name="description" rows="2"
|
||
placeholder="What is this model good at?">{{ model.description }}</textarea>
|
||
<p class="field__hint">Shown in the chat settings panel and your users' settings.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2 class="card__title">System prompt</h2>
|
||
<p class="card__lede">
|
||
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">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">Capabilities</h2>
|
||
<p class="card__lede">
|
||
What this endpoint can do. Endpoints rarely advertise it reliably, so it
|
||
is your call. <strong>reasoning</strong> shows the thinking block,
|
||
<strong>vision</strong> lets images be sent, and <strong>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.
|
||
</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">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>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>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">Availability</h2>
|
||
|
||
<div class="field">
|
||
<div class="checkbox-row">
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if model.enabled }}>
|
||
<span>Enabled — offered in chats</span>
|
||
</label>
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="pinned" value="true" {{ 'checked' if model.pinned }}>
|
||
<span>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>Available to everyone</span>
|
||
</label>
|
||
<p class="field__hint">
|
||
Uncheck to restrict this model to chosen groups. Administrators always
|
||
have access.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<span class="field__label">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">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">Save changes</button>
|
||
<a class="btn btn--ghost" href="/admin/models">Back to all models</a>
|
||
</div>
|
||
</form>
|
||
{% endblock %}
|