PWA, one send/stop button, audio in and out, web search as a tool

Four pieces of work.

**Installable.** A manifest carrying the instance name, PWA icons rasterised
from the existing mark at design time, a service worker and a themed offline
page. The worker caches the shell only and bails out on /api/, /auth/, /admin/
and anything accepting text/event-stream -- passing a reply stream through a
worker turns it into one delivery at the end, or nothing. It is served from
GET /sw.js rather than the static mount because a worker's scope is the path it
came from.

**Send and Stop are one button.** They were two, and the hidden one was never
hidden: `.btn` is display: inline-flex, which outranks the browser's own
`[hidden] { display: none }`, so Stop sat permanently beside Send. app.css now
forces the attribute to win -- every control toggled with `hidden` depended on
that -- and the composer renders one button carrying both icons, with ui.js
flipping data-composer-action and the type with it.

**Audio.** Speech to text and text to speech against any OpenAI-shaped
/v1/audio/* endpoint: dictate into the composer, have a reply read out.
Instance settings in Admin, per-reader overrides in Settings, with the voice
list discovered from the server where it offers one. Recorded audio is capped
and never written to disk -- it is not an attachment, it has no owner, and
nothing would ever sweep it.

**Web search, as a tool.** This is the tool loop PLAN.md described as the real
work: one reply is now a bounded sequence of requests rather than one. The model
asks, the tool runs, the result goes back and it is asked again, up to three
rounds. Providers are DuckDuckGo (no setup), SearXNG and Firecrawl.

Two decisions worth stating. Tools are only offered to models flagged `tools`,
because an endpoint without support rejects the whole request rather than
ignoring the array -- the same reason images only reach models flagged
`vision`. And tool results are not replayed as context on the next turn, for the
same reasons reasoning is not: the answer already contains what the model made
of them, and replaying stale results into every later request wastes the window
and reliably sends a small model into a search loop. The sources stay visible in
the transcript instead.

Search results are untrusted third-party text and are treated as such: escaped,
and only http/https URLs rendered as links.

338 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 17:56:50 +02:00
parent de178837b8
commit 7456525d19
63 changed files with 4597 additions and 140 deletions
@@ -0,0 +1,20 @@
{% from "_macros.html" import icon %}
{#
The outcome of one endpoint test, swapped into the card that asked for it.
A tts test also brings back a fresh voice list, because "did it work" and
"what can it say it in" are the same question asked twice otherwise.
#}
<div class="alert alert--{{ 'error' if message_kind == 'error' else 'success' }}"
id="audio-test-{{ side }}">
{{ icon("warning" if message_kind == "error" else "check", "alert__icon") }}
<span>{{ message }}</span>
</div>
{% if side == "tts" %}
<select class="select" id="tts-voice" name="tts_voice" hx-swap-oob="true">
{% with selected = values.tts_voice %}
{% include "partials/_voice_options.html" %}
{% endwith %}
</select>
{% endif %}
@@ -35,6 +35,14 @@
{{ icon("sliders", "icon--sm") }}
<span class="nav-item__label">Models</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'audio' }}" href="/admin/audio">
{{ icon("speaker", "icon--sm") }}
<span class="nav-item__label">Audio</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'search' }}" href="/admin/search">
{{ icon("globe", "icon--sm") }}
<span class="nav-item__label">Web search</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'users' }}" href="/admin/users">
{{ icon("user", "icon--sm") }}
<span class="nav-item__label">Users</span>
@@ -0,0 +1,31 @@
{% from "_macros.html" import icon %}
{#
The outcome of a test search.
The results are third-party text and are shown here exactly as a chat would
show them: escaped, and with the URL as text rather than as a link. Nobody
needs to click through from a connectivity test, so this does not offer the
chance.
#}
<div id="search-test">
<div class="alert alert--{{ 'error' if message_kind == 'error' else 'success' }}">
{{ icon("warning" if message_kind == "error" else "check", "alert__icon") }}
<span>{{ message }}</span>
</div>
{% if results %}
<ul class="model-list">
{% for result in results %}
<li class="model-list__item">
<div style="min-width: 0">
<strong>{{ result.title }}</strong>
<div class="text-xs faint mono">{{ result.url }}</div>
{% if result.snippet %}
<div class="text-xs faint">{{ result.snippet }}</div>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
+184
View File
@@ -0,0 +1,184 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "audio" %}
{% block title %}Audio - LLeMbas{% endblock %}
{% block heading %}Audio{% endblock %}
{% block admin_content %}
<p class="admin-lede">
Two endpoints speaking the OpenAI audio API: one that turns speech into text
so a message can be dictated, one that reads a reply out. They are configured
separately because they usually are separate servers — whisper.cpp and Kokoro,
say, or Speaches for both.
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Audio settings saved.</span></div>
{% endif %}
<form method="post" action="/admin/audio">
<section class="card">
<h2 class="card__title">
Dictation
{% if values.stt_enabled %}<span class="badge badge--success">on</span>
{% else %}<span class="badge">off</span>{% endif %}
</h2>
<p class="card__lede">
Adds a microphone to the composer. Recordings are sent to this endpoint
and never written to disk.
</p>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="stt_enabled" value="true"
{{ 'checked' if values.stt_enabled }}>
<span>Allow messages to be dictated</span>
</label>
</div>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="stt-base-url">Base URL</label>
<input class="input" id="stt-base-url" name="stt_base_url" type="url"
value="{{ values.stt_base_url }}" placeholder="http://127.0.0.1:8081">
<p class="field__hint">
Where <code>/v1/audio/transcriptions</code> lives — whisper.cpp's
<code>whisper-server</code>, Speaches, or anything else speaking it.
With or without <code>/v1</code>; either is understood.
</p>
</div>
<div class="field">
<label class="field__label" for="stt-api-key">API key</label>
<input class="input" id="stt-api-key" name="stt_api_key" type="password"
value="{{ unchanged if masked.stt else '' }}"
placeholder="{{ masked.stt or 'None needed for a local server' }}"
autocomplete="off">
<p class="field__hint">Encrypted at rest. Clear the field to remove it.</p>
</div>
<div class="field">
<label class="field__label" for="stt-model">Model</label>
<input class="input" id="stt-model" name="stt_model"
value="{{ values.stt_model }}" placeholder="whisper-1">
<p class="field__hint">
Sent even to servers that only host one; a router in front of several
needs it.
</p>
</div>
<div class="field">
<label class="field__label" for="stt-language">Language</label>
<input class="input" id="stt-language" name="stt_language" maxlength="16"
value="{{ values.stt_language }}" placeholder="detect">
<p class="field__hint">
An ISO code such as <code>en</code> or <code>sk</code>. Leave empty to
let the server detect it, which is what whisper does best.
</p>
</div>
</div>
<div class="btn-row">
<button class="btn" type="button" hx-post="/admin/audio/test/stt"
hx-target="#audio-test-stt" hx-swap="outerHTML">
{{ icon("refresh", "icon--sm") }} Test dictation
</button>
</div>
<div id="audio-test-stt"></div>
</section>
<section class="card">
<h2 class="card__title">
Read aloud
{% if values.tts_enabled %}<span class="badge badge--success">on</span>
{% else %}<span class="badge">off</span>{% endif %}
</h2>
<p class="card__lede">
Adds a speaker button to every reply. Each reader can pick their own voice
in their settings; what is chosen here is the default.
</p>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="tts_enabled" value="true"
{{ 'checked' if values.tts_enabled }}>
<span>Allow replies to be read out</span>
</label>
</div>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="tts-base-url">Base URL</label>
<input class="input" id="tts-base-url" name="tts_base_url" type="url"
value="{{ values.tts_base_url }}" placeholder="http://127.0.0.1:8880">
<p class="field__hint">
Where <code>/v1/audio/speech</code> lives — Kokoro-FastAPI, OpenAI, or
anything else speaking it.
</p>
</div>
<div class="field">
<label class="field__label" for="tts-api-key">API key</label>
<input class="input" id="tts-api-key" name="tts_api_key" type="password"
value="{{ unchanged if masked.tts else '' }}"
placeholder="{{ masked.tts or 'None needed for a local server' }}"
autocomplete="off">
</div>
<div class="field">
<label class="field__label" for="tts-model">Model</label>
<input class="input" id="tts-model" name="tts_model"
value="{{ values.tts_model }}" placeholder="tts-1">
</div>
<div class="field">
<label class="field__label" for="tts-voice">Default voice</label>
<select class="select" id="tts-voice" name="tts_voice">
{% with selected = values.tts_voice %}
{% include "partials/_voice_options.html" %}
{% endwith %}
</select>
<p class="field__hint">
{% if voice_error %}
Could not read the voice list: {{ voice_error }}
{% else %}
Read from the endpoint. Save and test to refresh it.
{% endif %}
</p>
</div>
<div class="field">
<label class="field__label" for="tts-format">Format</label>
<select class="select" id="tts-format" name="tts_format">
{% for format in formats %}
<option value="{{ format }}" {{ 'selected' if format == values.tts_format }}>
{{ format }}
</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field__label" for="tts-speed">Speed</label>
<input class="input" id="tts-speed" name="tts_speed" type="number"
min="0.25" max="4" step="0.05" value="{{ values.tts_speed }}">
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="tts_autoplay" value="true"
{{ 'checked' if values.tts_autoplay }}>
<span>Read new replies aloud as they finish, by default</span>
</label>
<p class="field__hint">
Only the starting value for each account — anyone can turn it off in
their own settings, and nobody is made to listen.
</p>
</div>
<div class="btn-row">
<button class="btn" type="button" hx-post="/admin/audio/test/tts"
hx-target="#audio-test-tts" hx-swap="outerHTML">
{{ icon("refresh", "icon--sm") }} Test speech
</button>
</div>
<div id="audio-test-tts"></div>
</section>
<div class="btn-row"><button class="btn btn--primary" type="submit">Save settings</button></div>
</form>
{% endblock %}
+150
View File
@@ -0,0 +1,150 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "search" %}
{% block title %}Web search - LLeMbas{% endblock %}
{% block heading %}Web search{% endblock %}
{% block admin_content %}
<p class="admin-lede">
Lets a model look things up while it answers. It is offered as a tool the
model chooses to call, so nothing changes for a question that does not need
it — and it is only offered to models marked as supporting tools, because
sending a tool list to one that does not fails the whole request.
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Search settings saved.</span></div>
{% endif %}
<form method="post" action="/admin/search">
<section class="card">
<h2 class="card__title">
Web search
{% if values.enabled %}<span class="badge badge--success">on</span>
{% else %}<span class="badge">off</span>{% endif %}
</h2>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if values.enabled }}>
<span>Offer web search to models that support tools</span>
</label>
<p class="field__hint">
Who may use it is a permission — <code>tools.web_search</code> under
Groups &amp; permissions.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">Provider</h2>
<div class="field">
{% for provider in providers %}
<label class="checkbox" style="align-items: flex-start">
<input type="radio" name="provider" value="{{ provider.key }}"
{{ 'checked' if values.provider == provider.key }}>
<span>
<strong>{{ provider.label }}</strong>
{% if not provider.needs_setup %}<span class="badge">no setup</span>{% endif %}
<div class="text-xs faint">{{ provider.description }}</div>
{% if problems[provider.key] %}
<div class="text-xs" style="color: var(--danger)">{{ problems[provider.key] }}</div>
{% endif %}
</span>
</label>
{% endfor %}
</div>
<div class="grid grid--3">
<div class="field">
<label class="field__label" for="max-results">Results per search</label>
<input class="input" id="max-results" name="max_results" type="number"
min="1" max="20" value="{{ values.max_results }}">
<p class="field__hint">A ceiling — a model asking for more gets this.</p>
</div>
<div class="field">
<label class="field__label" for="safesearch">Safe search</label>
<select class="select" id="safesearch" name="safesearch">
{% for option in safesearch_options %}
<option value="{{ option }}" {{ 'selected' if option == values.safesearch }}>
{{ option }}
</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field__label" for="timeout">Timeout (seconds)</label>
<input class="input" id="timeout" name="timeout" type="number"
min="5" max="120" step="1" value="{{ values.timeout }}">
</div>
</div>
<div class="field">
<label class="field__label" for="region">DuckDuckGo region</label>
<input class="input" id="region" name="region" maxlength="16"
value="{{ values.region }}" placeholder="wt-wt">
<p class="field__hint">
<code>wt-wt</code> is no region at all. <code>uk-en</code>,
<code>de-de</code> and so on bias results to a country.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">SearXNG</h2>
<p class="card__lede">
Only used when SearXNG is the chosen provider. Your own instance, so no
third party sees the queries.
</p>
<div class="field">
<label class="field__label" for="searxng-base-url">Instance URL</label>
<input class="input" id="searxng-base-url" name="searxng_base_url" type="url"
value="{{ values.searxng_base_url }}" placeholder="http://127.0.0.1:8888">
<p class="field__hint">
A stock SearXNG refuses JSON. Add <code>- json</code> under
<code>search.formats</code> in its <code>settings.yml</code> and restart
it, or every search will fail with that message.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">Firecrawl</h2>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="firecrawl-base-url">API URL</label>
<input class="input" id="firecrawl-base-url" name="firecrawl_base_url" type="url"
value="{{ values.firecrawl_base_url }}"
placeholder="https://api.firecrawl.dev">
<p class="field__hint">Change only for a self-hosted Firecrawl.</p>
</div>
<div class="field">
<label class="field__label" for="firecrawl-api-key">API key</label>
<input class="input" id="firecrawl-api-key" name="firecrawl_api_key" type="password"
value="{{ unchanged if masked else '' }}"
placeholder="{{ masked or 'fc-...' }}" autocomplete="off">
<p class="field__hint">Encrypted at rest. Clear the field to remove it.</p>
</div>
</div>
</section>
<div class="btn-row"><button class="btn btn--primary" type="submit">Save settings</button></div>
</form>
<section class="card">
<h2 class="card__title">Try it</h2>
<p class="card__lede">
Runs a real search against the <em>saved</em> settings, which is what a chat
would do. Save first if you have just changed something.
</p>
<div class="row" style="gap: var(--sp-2)">
<input class="input" id="test-query" name="query" placeholder="mallorn tree"
style="flex: 1">
<button class="btn" type="button" hx-post="/admin/search/test"
hx-include="#test-query" hx-target="#search-test" hx-swap="outerHTML">
{{ icon("search", "icon--sm") }} Search
</button>
</div>
<div id="search-test"></div>
</section>
{% endblock %}
+32
View File
@@ -8,6 +8,21 @@
<meta name="color-scheme" content="dark light">
<link rel="icon" href="{{ url_for('static', path='img/favicon.svg') }}" type="image/svg+xml">
{#
Installing as an app. The manifest is a route, not a file, because it carries
the instance name; the icons are PNG because a launcher will not take an SVG.
theme-color is rewritten by app.js when the theme changes -- the value here is
only what the browser paints with before the stylesheet has resolved.
#}
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#101317">
<link rel="apple-touch-icon" href="{{ url_for('static', path='img/apple-touch-icon-180.png') }}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="LLeMbas">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<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 %}
@@ -39,6 +54,23 @@
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script>
<script src="{{ url_for('static', path='js/app.js') }}" defer></script>
<script src="{{ url_for('static', path='js/ui.js') }}" defer></script>
<script src="{{ url_for('static', path='js/audio.js') }}" defer></script>
{#
The version in the query string is what versions the worker's cache, so a
release invalidates it without anyone remembering to bump a constant.
serviceWorker is absent over plain http, which is why a LAN install without
TLS silently offers no install prompt -- that is the browser's rule, not ours.
#}
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/sw.js?v={{ version }}").catch(function () {
/* An install failure must never break the page it was loaded from. */
});
});
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+28 -2
View File
@@ -59,9 +59,35 @@
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
{% if can_dictate %}
{# Recording is started and stopped by the same button; audio.js swaps
data-mic-state and the icon with it. #}
<button class="btn btn--icon composer__btn composer__mic" type="button"
data-mic data-mic-state="idle"
aria-label="Dictate a message" title="Dictate a message">
<span class="composer__icon composer__icon--mic">{{ icon("mic") }}</span>
<span class="composer__icon composer__icon--recording" aria-hidden="true">
{{ icon("stop-circle") }}
</span>
</button>
{% endif %}
{#
One button, two jobs. While a reply is being written it becomes Stop,
because that is where the hand already is and a second button sitting
permanently beside Send is clutter that is wrong most of the time.
ui.js flips data-composer-action, and the type with it: as `submit`
the form's own handler sends, as `button` the click handler stops.
Both icons are rendered here and chosen in CSS, so the swap costs no
layout and cannot flash an empty button.
#}
<button class="btn btn--primary btn--icon composer__btn" type="submit"
aria-label="Send">
{{ icon("send") }}
data-composer-action="send" aria-label="Send">
<span class="composer__icon composer__icon--send">{{ icon("send") }}</span>
<span class="composer__icon composer__icon--stop" aria-hidden="true">
<span class="composer__stop-square"></span>
</span>
</button>
</div>
</form>
+50 -13
View File
@@ -101,6 +101,12 @@
<div class="reasoning__body" sse-swap="reasoning" hx-swap="beforeend"></div>
</details>
{# Tool activity as it happens. Empty until the model asks for something,
and the whole block is replaced each time rather than appended to --
a follower attaching late has no earlier fragments to build on. #}
<div class="tool-activity-list" id="tools-{{ message.id }}"
sse-swap="tools" hx-swap="innerHTML"></div>
{# The server re-renders the answer as Markdown a few times a second and
replaces this whole block, so formatting appears as the model writes
rather than snapping into place at the end. #}
@@ -111,7 +117,11 @@
<div class="msg__waiting">
<span class="dots"><i></i><i></i><i></i></span>
</div>
{% elif message.reasoning and not message.error %}
{% else %}
{# Finished. Same order as the live view above -- thinking, then what it
looked up, then the answer -- so a reply does not rearrange itself the
moment it stops streaming. #}
{% if message.reasoning and not message.error %}
{# Collapsed once finished: the answer is what the reader came for, and
the thinking is there if they want to audit it. #}
<details class="reasoning" id="reasoning-{{ message.id }}">
@@ -128,9 +138,19 @@
</summary>
<div class="reasoning__body">{{ message.reasoning }}</div>
</details>
<div class="msg__body">{{ body_html|safe }}</div>
{% endif %}
{% elif message.error %}
{% if message.tool_calls_json %}
{# Kept with the message rather than discarded with the stream, so the
sources behind an answer are still there tomorrow. #}
<div class="tool-activity-list">
{% with tool_events = message.tool_calls_json, live = false %}
{% include "chat/_tool_activity.html" %}
{% endwith %}
</div>
{% endif %}
{% if message.error %}
<div class="alert alert--error msg__error" role="alert">
{{ icon("warning", "alert__icon") }}
<div>
@@ -138,19 +158,21 @@
<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>
{% if message.stopped %}
<p class="msg__note">{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.</p>
{% if message.role == "assistant" %}
{% if message.content %}
<div class="msg__body">{{ body_html|safe }}</div>
{% endif %}
{% if message.stopped %}
<p class="msg__note">{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.</p>
{% endif %}
{% elif message.content %}
<div class="msg__body msg__body--plain">{{ message.content }}</div>
{% endif %}
{% elif message.content %}
<div class="msg__body msg__body--plain">{{ message.content }}</div>
{# An attachment-only turn has no text; rendering the bubble anyway would
leave an empty box under the file. #}
{% endif %}
{# An attachment-only turn has no text; rendering the bubble anyway would
leave an empty box under the file. #}
{% if not streaming %}
<footer class="msg__actions">
@@ -173,6 +195,21 @@
aria-label="Regenerate reply">
{{ icon("refresh", "icon--sm") }}
</button>
{% if can_listen | default(false) and message.content and not message.error %}
{# Speech is synthesised on demand rather than stored: the voice can
change under the reader between plays, and a reply can be regenerated
at the same address. #}
{# data-speak-auto is set only on the frame that ends a live stream, never
on a page load: reopening a chat must not start reading its last reply
out loud again. #}
<button class="btn btn--icon btn--sm" type="button"
data-speak="/api/audio/speech/{{ chat.id }}/{{ message.id }}"
{% if audio_autoplay | default(false) and just_finished | default(false) %}data-speak-auto{% endif %}
aria-label="Read this reply aloud">
<span class="speak__icon speak__icon--play">{{ icon("speaker", "icon--sm") }}</span>
<span class="speak__icon speak__icon--stop">{{ icon("stop-circle", "icon--sm") }}</span>
</button>
{% endif %}
{% endif %}
</footer>
{# The raw source, so the copy button yields Markdown rather than rendered
@@ -0,0 +1,61 @@
{% from "_macros.html" import icon %}
{#
What the model did before answering.
Rendered both live (streamed as a whole block, like the reasoning and the
answer) and from the stored message afterwards, so the sources behind an
answer stay in the transcript rather than vanishing when the stream ends.
EVERYTHING in here comes from a search provider and is untrusted, exactly as
much as model output is. Jinja autoescaping covers the text; the URL is
checked separately, because `is_linkable` is the only thing standing between
a result carrying a javascript: URL and an anchor pointing at it.
#}
{% for event in tool_events %}
<details class="tool-activity {{ 'tool-activity--error' if event.status == 'error' }}">
<summary class="tool-activity__summary">
{{ icon("globe", "icon--sm tool-activity__icon") }}
<span class="tool-activity__label">
{% if event.status == "error" %}
Web search failed
{% elif event.query %}
Searched the web for “{{ event.query }}”
{% else %}
Searched the web
{% endif %}
{% if event.results %}
<span class="tool-activity__count">
· {{ event.results | length }} result{{ '' if event.results | length == 1 else 's' }}
</span>
{% endif %}
</span>
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
</summary>
<div class="tool-activity__body">
{% if event.error %}
<p class="tool-activity__error">{{ event.error }}</p>
{% elif not event.results %}
<p class="tool-activity__error">Nothing was found.</p>
{% endif %}
{% for result in event.results %}
<div class="tool-result">
{% set scheme = result.url.split(":")[0] | lower %}
{% if scheme in ("http", "https") %}
<a class="tool-result__title" href="{{ result.url }}"
target="_blank" rel="noopener noreferrer nofollow">{{ result.title }}</a>
{% else %}
{# Not a link. A search result is third-party text and its URL is not
trusted to be safe to click. #}
<span class="tool-result__title">{{ result.title }}</span>
{% endif %}
<span class="tool-result__host">{{ result.host }}</span>
{% if result.snippet %}
<p class="tool-result__snippet">{{ result.snippet }}</p>
{% endif %}
</div>
{% endfor %}
</div>
</details>
{% endfor %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% from "_macros.html" import mark %}
{#
Shown by the service worker when a navigation cannot reach the server.
Cached at install time, so it has to stand entirely on its own: no user, no
chats, nothing that was rendered from the database. One of the few places
flavour belongs -- see the flavour rule in CLAUDE.md.
#}
{% block title %}Offline - LLeMbas{% endblock %}
{% block body %}
<main class="auth">
<div class="auth__card" style="text-align: center">
{{ mark(cls="empty__mark", uid="offline") }}
<h1 class="auth__title" style="margin-top: var(--sp-4)">No road from here</h1>
<p class="empty__text" style="margin: var(--sp-3) auto var(--sp-5)">
The Road goes ever on and on — but not without a connection.
</p>
<p class="text-sm muted" style="margin-bottom: var(--sp-5)">
LLeMbas answers from your server, so there is nothing to read until it can
be reached again.
</p>
<button class="btn btn--primary" type="button" onclick="window.location.reload()">
Try again
</button>
</div>
</main>
{% endblock %}
@@ -0,0 +1,25 @@
{#
The contents of a voice <select>.
A fragment rather than markup inside each settings page because the admin
form, a reader's own settings and the endpoint test all need the same list --
which is fetched from the speech endpoint rather than stored, since
reconfiguring the server changes what is on offer.
`instance_voice` is only passed by the user-facing page, where the first
option means "whatever the administrator chose"; on the admin page there is
no such fallback and the empty option means "let the endpoint decide".
#}
{% if instance_voice is defined and instance_voice %}
<option value="">Instance default — {{ instance_voice }}</option>
{% else %}
<option value="">Endpoint default</option>
{% endif %}
{% for voice in voices %}
<option value="{{ voice }}" {{ 'selected' if voice == selected }}>{{ voice }}</option>
{% endfor %}
{% if selected and selected not in voices %}
{# The stored choice is no longer offered -- kept so saving the form does not
silently reset it to the default. #}
<option value="{{ selected }}" selected>{{ selected }} (not currently offered)</option>
{% endif %}
@@ -136,6 +136,27 @@
<circle cx="8" cy="12" r="4"/>
<path d="M12 12h8M17.5 12v3M20 12v2.5"/>
</symbol>
<symbol id="i-mic" viewBox="0 0 24 24">
<rect x="9" y="3" width="6" height="10.5" rx="3"/>
<path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21M9 21h6"/>
</symbol>
<symbol id="i-speaker" viewBox="0 0 24 24">
<path d="M11 5 6.5 9H3.5v6h3L11 19Z"/>
<path d="M14.8 9.2a4 4 0 0 1 0 5.6M17.6 6.4a8 8 0 0 1 0 11.2"/>
</symbol>
<symbol id="i-stop-circle" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8.5"/>
<rect x="9.2" y="9.2" width="5.6" height="5.6" rx="1"/>
</symbol>
<symbol id="i-globe" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8.5"/>
<path d="M3.5 12h17M12 3.5c2.2 2.4 3.3 5.3 3.3 8.5S14.2 18.1 12 20.5c-2.2-2.4-3.3-5.3-3.3-8.5S9.8 5.9 12 3.5Z"/>
</symbol>
<symbol id="i-link" viewBox="0 0 24 24">
<path d="M10.5 13.5a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 0 0-5-5l-1.5 1.5"/>
<path d="M13.5 10.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5L12 17"/>
</symbol>
<symbol id="i-leaf" viewBox="0 0 64 64">
<path d="M20.5 45.5C13.8 31.7 23.8 20.9 45.5 18.5 49.8 35 39.8 45.8 20.5 45.5Z"/>
<path d="M20.5 45.5C28 38 36 29 45.5 18.5"/>
+107
View File
@@ -35,6 +35,11 @@
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-appearance">
<label class="tabs__tab" for="tab-appearance">{{ icon("sun", "icon--sm") }} Appearance</label>
{% if audio.stt_enabled or audio.tts_enabled %}
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-audio">
<label class="tabs__tab" for="tab-audio">{{ icon("speaker", "icon--sm") }} Audio</label>
{% endif %}
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-security">
<label class="tabs__tab" for="tab-security">{{ icon("key", "icon--sm") }} Security</label>
</div>
@@ -166,8 +171,110 @@
</button>
</div>
</div>
<div class="card">
<h2 class="card__title">Install as an app</h2>
<p class="card__lede">
Runs in its own window, without browser chrome. Everything still
comes from your server — there is no offline mode beyond a page
saying so.
</p>
{# Revealed by app.js only when the browser actually offers an
install. Firefox and desktop Safari never do, and a button that
does nothing is worse than no button. #}
<div class="btn-row" data-install-app hidden>
<button class="btn btn--primary" type="button"
onclick="window.lembas.promptInstall()">
{{ icon("plus", "icon--sm") }} Install
</button>
</div>
<p class="field__hint">
Only offered over HTTPS or on localhost, and not at all in some
browsers. On iOS, use Share → Add to Home Screen.
</p>
</div>
</section>
{# --- Audio --- #}
{% if audio.stt_enabled or audio.tts_enabled %}
<section class="tabs__panel" data-tab="tab-audio">
<form method="post" action="/api/preferences/audio">
{% if audio.tts_enabled %}
<div class="card">
<h2 class="card__title">Reading replies aloud</h2>
<p class="card__lede">
Overrides what the administrator chose, for you only.
</p>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="audio-voice">Voice</label>
{# Options are fetched from the speech endpoint rather than
stored, so the list follows the server. #}
<select class="select" id="audio-voice" name="voice">
{% with selected = user_audio.get('voice', ''),
instance_voice = audio.tts_voice %}
{% include "partials/_voice_options.html" %}
{% endwith %}
</select>
<p class="field__hint">
{% if voice_error %}
The voice list could not be read: {{ voice_error }}
{% else %}
{{ voices | length }} available.
{% endif %}
</p>
</div>
<div class="field">
<label class="field__label" for="audio-speed">Speed</label>
<input class="input" id="audio-speed" name="speed" type="number"
min="0.25" max="4" step="0.05"
value="{{ user_audio.get('speed', '') }}"
placeholder="{{ audio.tts_speed }}">
<p class="field__hint">Leave empty to use the instance default.</p>
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="autoplay" value="true"
{{ 'checked' if user_audio.get('autoplay', audio.tts_autoplay) }}>
<span>Read each reply aloud as it finishes</span>
</label>
<p class="field__hint">
Only replies that arrive while you are looking at the chat.
</p>
</div>
</div>
{% endif %}
{% if audio.stt_enabled %}
<div class="card">
<h2 class="card__title">Dictation</h2>
<div class="field">
<label class="field__label" for="audio-language">Language</label>
<input class="input" id="audio-language" name="language" maxlength="16"
value="{{ user_audio.get('language', '') }}"
placeholder="{{ audio.stt_language or 'detect' }}">
<p class="field__hint">
An ISO code such as <code>en</code> or <code>sk</code>. Empty
lets the server work it out, which is usually best.
</p>
</div>
<p class="field__hint">
The microphone needs HTTPS or localhost — browsers do not grant
it over plain HTTP.
</p>
</div>
{% endif %}
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save audio preferences</button>
</div>
</form>
</section>
{% endif %}
{# --- Security --- #}
<section class="tabs__panel" data-tab="tab-security">
<div class="card">