Working chat: auth, connections, streaming, folders
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>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
{#
|
||||
Shared template macros.
|
||||
|
||||
Import at the top of any template that needs them:
|
||||
{% from "_macros.html" import icon, brand, mark %}
|
||||
#}
|
||||
|
||||
{# An icon from the inlined sprite. `name` omits the "i-" prefix. #}
|
||||
{% macro icon(name, cls="") -%}
|
||||
<svg class="icon {{ cls }}" aria-hidden="true"><use href="#i-{{ name }}"/></svg>
|
||||
{%- endmacro %}
|
||||
|
||||
{#
|
||||
The leaf-and-wafer mark, inlined rather than referenced as <img> so it can
|
||||
scale with the surrounding font size. Gradient ids are suffixed with `uid`
|
||||
because ids are document-global: two marks on one page with the same ids
|
||||
means the second silently reuses the first one's gradients.
|
||||
#}
|
||||
{% macro mark(cls="brand-mark", uid="a") -%}
|
||||
<svg class="{{ cls }}" viewBox="0 0 64 64" role="img" aria-label="LLeMbas">
|
||||
<defs>
|
||||
<linearGradient id="mk-{{ uid }}-w" x1="0" y1="0" x2="0.3" y2="1">
|
||||
<stop offset="0" stop-color="#EACB74"/>
|
||||
<stop offset="0.5" stop-color="#C9A227"/>
|
||||
<stop offset="1" stop-color="#916F13"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="mk-{{ uid }}-l" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||
<stop offset="0" stop-color="#93A5B6"/>
|
||||
<stop offset="0.4" stop-color="#F1F6FA"/>
|
||||
<stop offset="1" stop-color="#B8C7D5"/>
|
||||
</linearGradient>
|
||||
<clipPath id="mk-{{ uid }}-c"><rect x="6" y="6" width="52" height="52" rx="13"/></clipPath>
|
||||
</defs>
|
||||
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#mk-{{ uid }}-w)"/>
|
||||
<g clip-path="url(#mk-{{ uid }}-c)" fill="none" stroke-linecap="round">
|
||||
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
|
||||
<path d="M32 6 V58"/><path d="M6 32 H58"/>
|
||||
</g>
|
||||
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
|
||||
<path d="M33.2 6 V58"/><path d="M6 33.2 H58"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9" fill="none"
|
||||
stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
|
||||
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
|
||||
stroke-linecap="round" fill="none"/>
|
||||
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z"
|
||||
fill="url(#mk-{{ uid }}-l)"/>
|
||||
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A"
|
||||
stroke-opacity="0.5" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
{%- endmacro %}
|
||||
|
||||
{#
|
||||
The wordmark as live text rather than the outlined SVG: it stays selectable,
|
||||
searchable and readable to a screen reader, and scales with the user's font
|
||||
size. The capitals spelling LLM take the gold accent.
|
||||
#}
|
||||
{% macro wordmark() -%}
|
||||
<span class="brand-llm">LL</span>e<span class="brand-llm">M</span>bas
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro brand(href="/", uid="a") -%}
|
||||
<a class="sidebar__brand" href="{{ href }}">
|
||||
{{ mark(uid=uid) }}
|
||||
<span>{{ wordmark() }}</span>
|
||||
</a>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,98 @@
|
||||
{% 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>
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon, brand %}
|
||||
{#
|
||||
Shared chrome for the admin area: its own narrow nav rather than the chat
|
||||
sidebar, so administration is visibly a different place from chatting.
|
||||
#}
|
||||
|
||||
{% 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">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar__header">
|
||||
{{ brand(uid="admin") }}
|
||||
</div>
|
||||
|
||||
<nav class="sidebar__scroll" aria-label="Administration">
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Administration</div>
|
||||
<a class="nav-item {{ 'is-active' if section == 'connections' }}"
|
||||
href="/admin/connections">
|
||||
{{ icon("server", "icon--sm") }}
|
||||
<span class="nav-item__label">Connections</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'models' }}" href="/admin/models">
|
||||
{{ icon("sliders", "icon--sm") }}
|
||||
<span class="nav-item__label">Models</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Not yet built</div>
|
||||
<span class="nav-item is-disabled">
|
||||
{{ icon("users", "icon--sm") }}
|
||||
<span class="nav-item__label">Users & groups</span>
|
||||
</span>
|
||||
<span class="nav-item is-disabled">
|
||||
{{ icon("gear", "icon--sm") }}
|
||||
<span class="nav-item__label">Tools</span>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar__footer">
|
||||
<a class="nav-item" href="/chat">
|
||||
{{ icon("chat", "icon--sm") }}
|
||||
<span class="nav-item__label">Back to chats</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<h1 class="topbar__title">{% block heading %}Administration{% endblock %}</h1>
|
||||
<button class="btn btn--icon" type="button" data-theme-toggle aria-label="Switch theme">
|
||||
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
||||
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="admin-scroll">
|
||||
<div class="admin-page">
|
||||
{% block admin_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,76 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "connections" %}
|
||||
|
||||
{% block title %}Connections - LLeMbas{% endblock %}
|
||||
{% block heading %}Connections{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Any endpoint that speaks the OpenAI HTTP API: OpenAI itself, or a local
|
||||
runner such as LM Studio, vLLM, llama.cpp or Ollama. LLeMbas asks each one
|
||||
for its model list and offers those models in the chat picker.
|
||||
</p>
|
||||
|
||||
{% if message %}
|
||||
<div class="alert alert--success">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Add a connection</h2>
|
||||
<form method="post" action="/admin/connections" class="form-grid">
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-name">Name</label>
|
||||
<input class="input" id="new-name" name="name" required placeholder="Local LM Studio">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-url">Base URL</label>
|
||||
<input class="input input--mono" id="new-url" name="base_url" required
|
||||
placeholder="http://localhost:1234/v1">
|
||||
<p class="field__hint">
|
||||
With or without the trailing <code>/v1</code> — both are accepted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-key">API key</label>
|
||||
<input class="input input--mono" id="new-key" name="api_key" type="password"
|
||||
autocomplete="off" placeholder="sk-…">
|
||||
<p class="field__hint">
|
||||
Encrypted before it is stored, and never sent back to the browser.
|
||||
Leave empty for endpoints that need no key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field field--actions">
|
||||
<button class="btn btn--primary" type="submit">
|
||||
{{ icon("plus", "icon--sm") }} Add and load models
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<h2 class="admin-section-title">
|
||||
Configured connections
|
||||
<span class="badge">{{ connections|length }}</span>
|
||||
</h2>
|
||||
|
||||
{% if not connections %}
|
||||
<div class="empty" style="padding: var(--sp-10) 0">
|
||||
{{ icon("server", "empty__mark") }}
|
||||
<p class="empty__text">
|
||||
Nothing configured yet. Add a connection above and its models appear here.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="connection-list">
|
||||
{% for connection in connections %}
|
||||
{% with masked = masked[connection.id],
|
||||
model_count = model_counts[connection.id] %}
|
||||
{% include "admin/_connection_row.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "models" %}
|
||||
|
||||
{% block title %}Models - LLeMbas{% endblock %}
|
||||
{% block heading %}Models{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Every model discovered on each connection. Disable the ones you do not want
|
||||
cluttering the chat picker — nothing is deleted, and re-running
|
||||
“Test & refresh” will not bring a disabled model back on.
|
||||
</p>
|
||||
|
||||
{% for connection in connections %}
|
||||
<section class="card">
|
||||
<h2 class="card__title">
|
||||
{{ connection.name }}
|
||||
<span class="badge">{{ connection.models|length }}</span>
|
||||
{% if not connection.enabled %}<span class="badge">connection disabled</span>{% endif %}
|
||||
</h2>
|
||||
|
||||
{% if not connection.models %}
|
||||
<p class="muted text-sm">
|
||||
No models loaded. Run “Test & refresh” on
|
||||
<a href="/admin/connections">the connections page</a>.
|
||||
</p>
|
||||
{% else %}
|
||||
<ul class="model-list">
|
||||
{% for model in connection.models %}
|
||||
<li class="model-list__item">
|
||||
<code class="model-list__id">{{ model.model_id }}</code>
|
||||
<form method="post" action="/admin/models/{{ model.id }}/toggle">
|
||||
<button class="btn btn--sm {{ 'btn--primary' if model.enabled }}" type="submit">
|
||||
{% if model.enabled %}{{ icon("check", "icon--sm") }} Enabled{% else %}Disabled{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% else %}
|
||||
<div class="empty" style="padding: var(--sp-10) 0">
|
||||
{{ icon("server", "empty__mark") }}
|
||||
<p class="empty__text">
|
||||
No connections yet. <a href="/admin/connections">Add one</a> to load models.
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon, mark, wordmark %}
|
||||
|
||||
{% block title %}Sign in - LLeMbas{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main class="auth">
|
||||
<div class="auth__card">
|
||||
<div class="auth__brand">
|
||||
{{ mark(cls="brand-mark", uid="auth") }}
|
||||
<h1 class="auth__title">{{ wordmark() }}</h1>
|
||||
<p class="auth__subtitle">Waybread for the long road of thought.</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert--error" role="alert">
|
||||
{{ icon("warning", "alert__icon") }}
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/auth/login" class="stack">
|
||||
<input type="hidden" name="next" value="{{ next|default('/') }}">
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="email">Email</label>
|
||||
<input class="input" type="email" id="email" name="email" required
|
||||
autocomplete="username" autofocus value="{{ email|default('') }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">Password</label>
|
||||
<input class="input" type="password" id="password" name="password" required
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<button class="btn btn--primary btn--block" type="submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
{% if allow_signup %}
|
||||
<p class="auth__footer">
|
||||
No account yet? <a href="/auth/register">Create one</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon, mark, wordmark %}
|
||||
|
||||
{% block title %}{% if first_run %}Set up LLeMbas{% else %}Create an account{% endif %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main class="auth">
|
||||
<div class="auth__card">
|
||||
<div class="auth__brand">
|
||||
{{ mark(cls="brand-mark", uid="auth") }}
|
||||
<h1 class="auth__title">{{ wordmark() }}</h1>
|
||||
{% if first_run %}
|
||||
<p class="auth__subtitle">
|
||||
Nobody has claimed this instance yet. The first account becomes its administrator.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="auth__subtitle">Create your account.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert--error" role="alert">
|
||||
{{ icon("warning", "alert__icon") }}
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/auth/register" class="stack">
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<input class="input" type="text" id="name" name="name" required autofocus
|
||||
autocomplete="name" value="{{ name|default('') }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="email">Email</label>
|
||||
<input class="input" type="email" id="email" name="email" required
|
||||
autocomplete="username" value="{{ email|default('') }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">Password</label>
|
||||
<input class="input" type="password" id="password" name="password" required
|
||||
autocomplete="new-password" minlength="8">
|
||||
<p class="field__hint">At least 8 characters.</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn--primary btn--block" type="submit">
|
||||
{% if first_run %}Create administrator account{% else %}Create account{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if not first_run %}
|
||||
<p class="auth__footer">
|
||||
Already have an account? <a href="/auth/login">Sign in</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="{{ theme }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}LLeMbas{% endblock %}</title>
|
||||
<meta name="description" content="LLeMbas - a web UI for your language models.">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
|
||||
<link rel="icon" href="{{ url_for('static', path='img/favicon.svg') }}" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/tokens.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}">
|
||||
{% block head %}{% endblock %}
|
||||
|
||||
{#
|
||||
Applied before first paint so a reload never flashes the wrong theme. The
|
||||
server already rendered data-theme from the signed-in user's preference; this
|
||||
only corrects it for a visitor whose local choice differs (or who is signed
|
||||
out, where the server has nothing to go on).
|
||||
#}
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("lembas-theme");
|
||||
if (stored && stored !== document.documentElement.dataset.theme) {
|
||||
document.documentElement.dataset.theme = stored;
|
||||
}
|
||||
} catch (e) { /* private mode: the server-rendered theme stands */ }
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body{% block body_attrs %}{% endblock %}>
|
||||
{% include "partials/icons.html" %}
|
||||
|
||||
{% block body %}{% endblock %}
|
||||
|
||||
<script src="{{ url_for('static', path='vendor/htmx.min.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='vendor/htmx-ext-sse.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='js/app.js') }}" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,93 @@
|
||||
{% from "_macros.html" import icon, mark %}
|
||||
{#
|
||||
One message bubble, in either of two states.
|
||||
|
||||
An incomplete assistant message renders the streaming shell: it carries the
|
||||
sse-connect that opens the reply stream. This is deliberately the ONLY thing
|
||||
that starts a generation, which means a page load showing an unfinished reply
|
||||
picks it up again -- reloading after a dropped connection retries rather than
|
||||
leaving a permanently half-written answer.
|
||||
|
||||
A complete message renders its finished body: Markdown for the assistant,
|
||||
escaped plain text for everyone else.
|
||||
#}
|
||||
{% set streaming = (message.role == "assistant" and not message.complete) %}
|
||||
|
||||
<article class="msg msg--{{ message.role }}" id="msg-{{ message.id }}"
|
||||
{% if streaming %}
|
||||
hx-ext="sse"
|
||||
sse-connect="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stream"
|
||||
sse-close="close"
|
||||
{% endif %}>
|
||||
|
||||
<div class="msg__gutter" aria-hidden="true">
|
||||
{% if message.role == "assistant" %}
|
||||
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
|
||||
{% else %}
|
||||
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="msg__main">
|
||||
<header class="msg__meta">
|
||||
<span class="msg__author">
|
||||
{{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }}
|
||||
</span>
|
||||
{% if message.model_id %}
|
||||
<span class="msg__model" title="{{ message.model_id }}">{{ message.model_id }}</span>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if streaming %}
|
||||
{# Tokens are appended here as they arrive. The cursor is a CSS
|
||||
pseudo-element on the empty parent, so it disappears by itself once
|
||||
the first token lands. #}
|
||||
<div class="msg__body msg__body--streaming" id="stream-{{ message.id }}"
|
||||
sse-swap="token" hx-swap="beforeend"></div>
|
||||
<div class="msg__waiting">
|
||||
<span class="dots"><i></i><i></i><i></i></span>
|
||||
</div>
|
||||
{% elif message.error %}
|
||||
<div class="alert alert--error msg__error" role="alert">
|
||||
{{ icon("warning", "alert__icon") }}
|
||||
<div>
|
||||
<strong>The reply could not be completed.</strong>
|
||||
<div class="text-sm" style="margin-top: var(--sp-1)">{{ message.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if message.content %}
|
||||
<div class="msg__body">{{ body_html|safe }}</div>
|
||||
{% endif %}
|
||||
{% elif message.role == "assistant" %}
|
||||
<div class="msg__body">{{ body_html|safe }}</div>
|
||||
{% else %}
|
||||
<div class="msg__body msg__body--plain">{{ message.content }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not streaming %}
|
||||
<footer class="msg__actions">
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
|
||||
{{ icon("copy", "icon--sm") }}
|
||||
</button>
|
||||
{% if message.role == "assistant" %}
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/regenerate"
|
||||
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
|
||||
aria-label="Regenerate reply">
|
||||
{{ icon("refresh", "icon--sm") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</footer>
|
||||
{# The raw source, so the copy button yields Markdown rather than rendered
|
||||
text. A hidden div and not a <script>: script content is raw text, so
|
||||
the escaping Jinja applies would be copied out literally as entities. #}
|
||||
<div hidden id="msg-body-{{ message.id }}">{{ message.content }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if streaming %}
|
||||
{# Receives the finished bubble and replaces this whole article with it. #}
|
||||
<div hidden sse-swap="done" hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"></div>
|
||||
{% endif %}
|
||||
</article>
|
||||
@@ -0,0 +1,10 @@
|
||||
{#
|
||||
Out-of-band updates sent alongside the finished reply.
|
||||
|
||||
A chat is named from its first exchange, which happens on the server while
|
||||
the reply streams. These two fragments push the new title into the page
|
||||
without the client having to poll or reload.
|
||||
#}
|
||||
<span id="chat-title" hx-swap-oob="true">{{ chat.title }}</span>
|
||||
<span id="chat-link-label-{{ chat.id }}" hx-swap-oob="true"
|
||||
class="nav-item__label">{{ chat.title }}</span>
|
||||
@@ -0,0 +1,11 @@
|
||||
{#
|
||||
One exchange: the user's message plus the empty assistant bubble that will
|
||||
stream into it. Returned by POST /api/chats/{id}/messages and appended to the
|
||||
thread in a single swap, so the pair always arrives together.
|
||||
#}
|
||||
{% with message = user_message %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
{% with message = assistant_message %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
@@ -0,0 +1,120 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon, mark %}
|
||||
|
||||
{% block title %}{{ chat.title if chat else "Chats" }} - LLeMbas{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="shell">
|
||||
{% include "partials/sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button class="btn btn--icon" type="button" aria-label="Toggle sidebar"
|
||||
onclick="document.getElementById('sidebar').toggleAttribute('hidden')">
|
||||
{{ icon("sidebar") }}
|
||||
</button>
|
||||
|
||||
{% if chat %}
|
||||
<h1 class="topbar__title"><span id="chat-title">{{ chat.title }}</span></h1>
|
||||
|
||||
{% if models %}
|
||||
<form hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change from:find select">
|
||||
<select class="select select--compact" name="model_id" aria-label="Model">
|
||||
{% for model in models %}
|
||||
<option value="{{ model.model_id }}" {{ 'selected' if model.model_id == chat.model_id }}>
|
||||
{{ model.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h1 class="topbar__title">Chats</h1>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if not chat %}
|
||||
{# No chat selected. #}
|
||||
<div class="empty">
|
||||
{{ mark(cls="empty__mark", uid="empty") }}
|
||||
<h2 class="empty__title">The road goes ever on</h2>
|
||||
<p class="empty__text">
|
||||
Pick a chat from the side, or start a new one.
|
||||
</p>
|
||||
<button class="btn btn--primary" hx-post="/api/chats" hx-swap="none">
|
||||
{{ icon("plus", "icon--sm") }} New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% elif not models %}
|
||||
{# Nothing to talk to yet. This is the state every fresh install lands in,
|
||||
so it points straight at the fix rather than just reporting a problem. #}
|
||||
<div class="empty">
|
||||
{{ icon("server", "empty__mark") }}
|
||||
<h2 class="empty__title">No models available</h2>
|
||||
<p class="empty__text">
|
||||
{% if user.is_admin %}
|
||||
Add an OpenAI-compatible connection and LLeMbas will load its models.
|
||||
{% else %}
|
||||
No model connections have been set up yet. Ask an administrator.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if user.is_admin %}
|
||||
<a class="btn btn--primary" href="/admin/connections">
|
||||
{{ icon("server", "icon--sm") }} Set up a connection
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="thread-scroll" id="thread-scroll">
|
||||
<div class="thread" id="thread">
|
||||
{% if not messages %}
|
||||
<div class="thread__intro">
|
||||
{{ mark(cls="empty__mark", uid="intro") }}
|
||||
<h2 class="empty__title">What would you ask?</h2>
|
||||
<p class="empty__text">Speak, friend, and enter.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for message in messages %}
|
||||
{# Markdown was rendered server-side in pages.py, keyed by message
|
||||
id, so this loop stays a lookup rather than a render. #}
|
||||
{% with body_html = bodies.get(message.id, "") %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<form class="composer__form"
|
||||
hx-post="/api/chats/{{ chat.id }}/messages"
|
||||
hx-target="#thread" hx-swap="beforeend"
|
||||
hx-on::after-request="if (event.detail.successful) {
|
||||
this.reset();
|
||||
const t = this.querySelector('textarea');
|
||||
window.lembas.autosize(t);
|
||||
window.lembas.scrollThread(true);
|
||||
}">
|
||||
<textarea class="composer__input" name="content" rows="1"
|
||||
data-autosize data-max-height="320" data-composer-input
|
||||
placeholder="Send a message…" aria-label="Message"></textarea>
|
||||
<button class="btn btn--primary composer__send" type="submit" aria-label="Send">
|
||||
{{ icon("send", "icon--sm") }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="composer__hint">
|
||||
Enter to send, Shift+Enter for a new line.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import mark %}
|
||||
|
||||
{% block title %}{{ status_code }} - LLeMbas{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main class="auth">
|
||||
<div class="auth__card" style="text-align: center">
|
||||
{{ mark(cls="empty__mark", uid="err") }}
|
||||
<h1 class="auth__title" style="margin-top: var(--sp-4)">{{ status_code }}</h1>
|
||||
<p class="empty__text" style="margin: var(--sp-3) auto var(--sp-5)">{{ flavour }}</p>
|
||||
{% if detail and detail != flavour %}
|
||||
<p class="text-sm muted" style="margin-bottom: var(--sp-5)">{{ detail }}</p>
|
||||
{% endif %}
|
||||
<a class="btn btn--primary" href="/">Back to your chats</a>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
A single chat row. `chat_item` rather than `chat` so this can be included
|
||||
from a page that already has the open chat bound to `chat`.
|
||||
#}
|
||||
<div class="nav-item {% if chat and chat.id == chat_item.id %}is-active{% endif %}"
|
||||
data-chat-id="{{ chat_item.id }}">
|
||||
<a class="nav-item__link" href="/chat/{{ chat_item.id }}">
|
||||
{{ icon("chat", "icon--sm") }}
|
||||
<span class="nav-item__label" id="chat-link-label-{{ chat_item.id }}">{{ chat_item.title }}</span>
|
||||
</a>
|
||||
<span class="nav-item__actions">
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
hx-delete="/api/chats/{{ chat_item.id }}"
|
||||
hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone."
|
||||
hx-swap="none"
|
||||
aria-label="Delete chat">
|
||||
{{ icon("trash", "icon--sm") }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
A folder and everything under it. Recursive: a folder includes this same
|
||||
template for each of its children, so nesting has no fixed depth limit.
|
||||
|
||||
Open/closed state is local to the browser (Alpine), not persisted per
|
||||
request -- collapsing a folder should not cost a round trip.
|
||||
#}
|
||||
<div class="folder" x-data="{ open: {{ 'false' if folder.collapsed else 'true' }} }">
|
||||
<div class="nav-item folder__row">
|
||||
<button class="folder__toggle" type="button" @click="open = !open"
|
||||
:aria-expanded="open ? 'true' : 'false'">
|
||||
<span class="folder__chevron" :class="open && 'is-open'">
|
||||
{{ icon("chevron-right", "icon--sm") }}
|
||||
</span>
|
||||
<span x-show="open">{{ icon("folder-open", "icon--sm") }}</span>
|
||||
<span x-show="!open" x-cloak>{{ icon("folder", "icon--sm") }}</span>
|
||||
<span class="nav-item__label">{{ folder.name }}</span>
|
||||
</button>
|
||||
<span class="nav-item__actions">
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
hx-delete="/api/folders/{{ folder.id }}"
|
||||
hx-confirm="Delete the folder “{{ folder.name }}”? Chats inside it are kept."
|
||||
hx-swap="none"
|
||||
aria-label="Delete folder">
|
||||
{{ icon("trash", "icon--sm") }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="folder__contents" x-show="open" x-cloak>
|
||||
{% for child in folder.children %}
|
||||
{% with folder = child %}
|
||||
{% include "partials/_folder.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
|
||||
{% for chat_item in folder.chats %}
|
||||
{% include "partials/_chat_link.html" %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not folder.children and not folder.chats %}
|
||||
<p class="nav-empty">Empty</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
{% from "_macros.html" import icon, brand %}
|
||||
{#
|
||||
Sidebar: brand, new chat, the folder tree, then unfiled chats.
|
||||
|
||||
Folders render recursively through _folder.html. Chats appear under their
|
||||
folder, and any chat without one falls to the flat list at the bottom.
|
||||
#}
|
||||
<aside class="sidebar" id="sidebar" x-data="{ }">
|
||||
<div class="sidebar__header">
|
||||
{{ brand(uid="side") }}
|
||||
</div>
|
||||
|
||||
<div class="sidebar__actions">
|
||||
<button class="btn btn--primary btn--block" hx-post="/api/chats" hx-swap="none">
|
||||
{{ icon("plus", "icon--sm") }} New chat
|
||||
</button>
|
||||
<button class="btn btn--icon" hx-post="/api/folders" hx-swap="none"
|
||||
hx-vals='{"name": "New folder"}' aria-label="New folder" title="New folder">
|
||||
{{ icon("folder") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar__scroll" id="sidebar-tree" aria-label="Chats">
|
||||
{% if folders %}
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Folders</div>
|
||||
{% for folder in folders %}
|
||||
{% include "partials/_folder.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Chats</div>
|
||||
{% if unfiled_chats %}
|
||||
{% for chat_item in unfiled_chats %}
|
||||
{% include "partials/_chat_link.html" %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="nav-empty">No chats yet. The road begins here.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar__footer">
|
||||
<div class="row row--between">
|
||||
<a class="nav-item" href="/settings" style="flex: 1">
|
||||
{{ icon("user", "icon--sm") }}
|
||||
<span class="nav-item__label">{{ user.name }}</span>
|
||||
{% if user.is_admin %}<span class="badge badge--gold">admin</span>{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="row" style="gap: var(--sp-1); margin-top: var(--sp-1)">
|
||||
{% if user.is_admin %}
|
||||
<a class="btn btn--icon" href="/admin" aria-label="Admin settings" title="Admin settings">
|
||||
{{ icon("shield") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<button class="btn btn--icon" type="button" data-theme-toggle
|
||||
aria-label="Switch theme" title="Switch theme">
|
||||
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
||||
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
|
||||
</button>
|
||||
<span class="topbar__spacer"></span>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button class="btn btn--icon" type="submit" aria-label="Sign out" title="Sign out">
|
||||
{{ icon("logout") }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
|
||||
{% 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>
|
||||
|
||||
<div class="admin-scroll">
|
||||
<div class="admin-page">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Account</h2>
|
||||
<div class="field">
|
||||
<span class="field__label">Name</span>
|
||||
<p>{{ user.name }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field__label">Email</span>
|
||||
<p class="mono text-sm">{{ user.email }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field__label">Role</span>
|
||||
<p>
|
||||
<span class="badge {{ 'badge--gold' if user.is_admin }}">{{ user.role }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Appearance</h2>
|
||||
<div class="row" style="gap: var(--sp-3)">
|
||||
<button class="btn" type="button" onclick="window.lembas.applyTheme('moria')">
|
||||
{{ icon("moon", "icon--sm") }} Moria
|
||||
</button>
|
||||
<button class="btn" type="button" onclick="window.lembas.applyTheme('shire')">
|
||||
{{ icon("sun", "icon--sm") }} Shire
|
||||
</button>
|
||||
</div>
|
||||
<p class="field__hint" style="margin-top: var(--sp-3)">
|
||||
Moria is the dark theme, Shire the light one. Your choice is saved
|
||||
to this browser and to your account.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section 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>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user