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:
Jaroslav Beneš
2026-07-21 11:04:13 +02:00
parent 5ef2af6a9f
commit 0f44e8d24c
58 changed files with 6095 additions and 12 deletions
+68
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 %}
+47
View File
@@ -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 %}
+43
View File
@@ -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>
+11
View File
@@ -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 %}
+120
View File
@@ -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 %}
+18
View File
@@ -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>
+70
View File
@@ -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 %}