0f44e8d24c
LLeMbas now runs end to end. Register, add an OpenAI-compatible connection, and hold a real streaming conversation organised into folders. Verified against the local llama-swap instance. Streaming is the one genuinely tricky part. Sending a message returns two HTML fragments -- the user bubble and an empty assistant bubble carrying an sse-connect -- and that attribute is the ONLY thing that starts a generation. Rendering an incomplete assistant message as a streaming shell falls out of the same template, which means loading a page whose last reply never finished simply picks it up again. Details worth knowing about, each commented where it matters: - SSE payloads are split across several data: lines. A raw newline in one data: line truncates the event, which shows up the first time a model emits a code block. - Markdown is rendered server-side by the same helper for both the page and the final streamed frame, so the two cannot disagree. The fence renderer is replaced outright rather than using markdown-it's highlight option, which re-wraps output in a second <pre>. - escape_text is html.escape, not nh3.clean_text: it escapes character by character, so escaping stream chunks separately equals escaping the whole string. - The stream opens its own session via session_scope(); it outlives the request handler and the dependency-scoped session may be closed. - Deleting a folder keeps the chats inside it (FK is SET NULL). Losing a conversation to a mis-clicked folder delete is unforgivable. - Login failures use one message for "no such account" and "wrong password" so the form cannot enumerate registered addresses. Also adds deploy/ for the gamebox install at https://chat.lan: system unit, nginx vhost with buffering off (buffering on turns streaming into one lump at the end), and install/update scripts following the same service-user and /srv bind-mount conventions as llama-swap and comfyui. 70 tests, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
3.9 KiB
HTML
99 lines
3.9 KiB
HTML
{% from "_macros.html" import icon %}
|
|
{#
|
|
One connection: an editable form plus its current status.
|
|
|
|
Swapped in place by the "Test & refresh" button, so this fragment has to be
|
|
able to render on its own as well as inside the list.
|
|
#}
|
|
<section class="card connection" id="connection-{{ connection.id }}">
|
|
<form method="post" action="/admin/connections/{{ connection.id }}" class="form-grid">
|
|
<div class="connection__head">
|
|
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
|
<span class="status-dot {{ 'is-ok' if connection.enabled and not connection.last_error
|
|
else 'is-bad' if connection.last_error else 'is-off' }}"
|
|
aria-hidden="true"></span>
|
|
<strong class="truncate">{{ connection.name }}</strong>
|
|
{% if model_count is defined %}
|
|
<span class="badge">{{ model_count }} model{{ '' if model_count == 1 else 's' }}</span>
|
|
{% endif %}
|
|
{% if not connection.enabled %}
|
|
<span class="badge">disabled</span>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="row" style="gap: var(--sp-2)">
|
|
<button class="btn btn--sm" type="submit"
|
|
hx-post="/admin/connections/{{ connection.id }}/test"
|
|
hx-target="#connection-{{ connection.id }}" hx-swap="outerHTML"
|
|
formnovalidate>
|
|
{{ icon("refresh", "icon--sm") }} Test & refresh
|
|
</button>
|
|
<button class="btn btn--sm btn--primary" type="submit">Save</button>
|
|
</div>
|
|
</div>
|
|
|
|
{% if message %}
|
|
<div class="alert alert--{{ message_kind|default('success') }}">
|
|
{% if message_kind == "error" %}{{ icon("warning", "alert__icon") }}{% endif %}
|
|
<span>{{ message }}</span>
|
|
</div>
|
|
{% elif connection.last_error %}
|
|
<div class="alert alert--error">
|
|
{{ icon("warning", "alert__icon") }}
|
|
<span>{{ connection.last_error }}</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="field">
|
|
<label class="field__label" for="name-{{ connection.id }}">Name</label>
|
|
<input class="input" id="name-{{ connection.id }}" name="name"
|
|
value="{{ connection.name }}" required>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label class="field__label" for="url-{{ connection.id }}">Base URL</label>
|
|
<input class="input input--mono" id="url-{{ connection.id }}" name="base_url"
|
|
value="{{ connection.base_url }}" required>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label class="field__label" for="key-{{ connection.id }}">API key</label>
|
|
<input class="input input--mono" id="key-{{ connection.id }}" name="api_key"
|
|
type="password" autocomplete="off"
|
|
value="{{ unchanged if connection.api_key_encrypted else '' }}"
|
|
placeholder="No key set">
|
|
<p class="field__hint">
|
|
{% if connection.api_key_encrypted %}
|
|
Currently <code>{{ masked }}</code>. Leave the dots alone to keep it,
|
|
or clear the field to remove the key entirely.
|
|
{% else %}
|
|
No key is stored for this endpoint.
|
|
{% endif %}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label class="checkbox">
|
|
<input type="checkbox" name="enabled" value="true"
|
|
{{ 'checked' if connection.enabled }}>
|
|
<span>Enabled — its models are offered in chats</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="connection__footer">
|
|
<span class="text-xs faint">
|
|
{% if connection.last_checked_at %}
|
|
Last checked {{ connection.last_checked_at.strftime("%Y-%m-%d %H:%M") }} UTC
|
|
{% else %}
|
|
Never checked
|
|
{% endif %}
|
|
</span>
|
|
<button class="btn btn--sm btn--danger" type="submit"
|
|
formaction="/admin/connections/{{ connection.id }}/delete" formnovalidate
|
|
onclick="return confirm('Delete the connection “{{ connection.name }}”? Existing chats keep their history.')">
|
|
{{ icon("trash", "icon--sm") }} Delete
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|