Files
LLeMbas/src/lembas/web/templates/chat/index.html
T
Jaroslav Beneš 0bee366488 The menu that never appeared, and the reason it never did
composer.js built its menu lazily inside show(), and refresh() wrote
list.innerHTML before calling it. `list` is null until build() has run, so the
first `/` or `@` ever typed threw a TypeError and took the handler with it. The
menu has never appeared in any browser. That is why /compact "isn't there":
nothing was. I shipped it having only run `node --check`, which parses the file
happily.

So this also brings the thing that catches it: a DOM stub driven under node --
not committed, hard rule 1 stands, it is an instrument like curl. It reproduced
the crash in one run and immediately found two more: choosing a command from the
menu left `/help` sitting in the box so the next Enter ran it again, and Tab
completed nothing. Tab now completes and Enter runs, which is the split that
matters for a command taking an argument.

`.select--sm` was used three times and defined nowhere. I deleted the copy in
chat.css and left a comment saying it "is defined once, in app.css", where it
did not exist -- so those selects fell back to plain `.select`: width 100% in a
flex row where four siblings wanted the same, all of them shrinking together
until each was a few characters wide, and half a rem taller than everything
beside them. That was the whole of "the connection switch needs to be wider".

The connection and directory move to the topbar. They cannot change -- update_chat
refuses both with a 409 -- so they are facts about the chat, of a kind with the
Temporary badge, not controls on the message. The mode stays by the box.

Compaction says it is working. It makes a model call that takes seconds and had
no indicator anywhere: `hx-indicator` appears nowhere in this codebase, and the
Generation.status channel that says "Summarising earlier messages…" for the
automatic path cannot be borrowed, because it lives in the streaming bubble and
this endpoint refuses to run while any message is unfinished. The overflow menu
now runs the same code as /compact rather than posting for itself, so there is
one implementation, one spinner, and one place the endpoint's four carefully
written 409s finally reach somebody.

/effort, low medium high, per chat with a per-model default. It goes out twice
because there is no field that works everywhere: OpenAI and vLLM read
reasoning_effort, llama.cpp's own docs say other values "have no effect" and its
maintainer says the field "simply gets dropped without error or logging" -- what
reaches gpt-oss behind it is chat_template_kwargs. Both are sent, and only once
an effort has been chosen, so a provider strict about unknown parameters sees
exactly the request it always did until somebody opts in. The control appears
only on a model marked `reasoning`, a flag that has existed since the beginning
with no reader at all.

Mentions and recognised commands are marked as you type -- a mirror behind the
textarea holding the same text with every character transparent, contributing
nothing but a rounded rectangle, so a pixel of drift is a misplaced rectangle
rather than a doubled glyph. A command is marked only when it resolves, so
`/thoughts on this` visibly is not one before you send it. And again in the
transcript, where user turns had no render step at all and now escape before
they inject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:17:04 +02:00

322 lines
14 KiB
HTML

{% extends "base.html" %}
{% from "_macros.html" import icon, mark, model_avatar %}
{% block title %}{{ chat.title if chat else "New chat" }} - LLeMbas{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
{% if terminal_enabled %}
<link rel="stylesheet" href="{{ url_for('static', path='vendor/xterm.css') }}">
{% endif %}
{% 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"
aria-expanded="true" data-toggle="#sidebar">
{{ icon("sidebar") }}
</button>
<h1 class="topbar__title">
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
{# Beside the title because it describes the chat rather than acting on
it. The way out of it is Keep, in the overflow menu. #}
{% if chat and chat.temporary %}
<span class="badge badge--warning"
title="Not listed in the sidebar, and removed 24 hours after the last message.">
Temporary
</span>
{% endif %}
</h1>
{# Where this chat runs. Beside the title because it describes the chat
and cannot be changed -- update_chat refuses the connection and the
directory with a 409 -- so it is of a kind with the Temporary badge
rather than with the controls on the right. The mode is the one thing
here that moves, and it stays down by the message box. #}
{% if chat and chat.kind == "agent" %}
<span class="topbar__where" title="{{ chat.project_dir }}">
{{ icon("bolt", "icon--sm") }}
<span class="topbar__where-name">
{{ agent_profile.name if agent_profile else "connection missing" }}
</span>
<span class="topbar__where-dir">{{ chat.project_dir }}</span>
</span>
{% endif %}
<div class="topbar__actions">
{#
A link, not a script: the flag lives in the URL, so it survives a
reload and can be bookmarked.
#}
{% if not chat and can.get("chat.create") %}
<a class="btn btn--icon {{ 'is-active' if starting_temporary }}"
href="{{ '/chat' if starting_temporary else '/chat?temporary=1' }}"
aria-label="Temporary chat"
title="{% if starting_temporary %}Starting a temporary chat. Click to go back to a normal one.{% else %}Start a temporary chat: not listed in the sidebar, and removed after a day.{% endif %}">
{{ icon("clock") }}
</a>
{% endif %}
{% if models %}
{% if can.get("chat.model_select") or not chat %}
{% include "chat/_model_picker.html" %}
{% elif current_model %}
<span class="badge">{{ current_model.label }}</span>
{% endif %}
{% endif %}
{% if terminal_enabled %}
{# To the left of the inspector, and never open beside it: see the
toggle group in app.js. #}
<button class="btn btn--icon" type="button" aria-label="Terminal"
title="Open a shell on {{ agent_profile.name if agent_profile else 'this connection' }}"
aria-expanded="false" data-toggle="#terminal" data-toggle-group="side">
{{ icon("terminal") }}
</button>
{% endif %}
{% if chat and user.is_admin %}
<button class="btn btn--icon" type="button" aria-label="Inspect this chat"
title="Inspect this chat" aria-expanded="false" data-toggle="#inspector"
data-toggle-group="side">
{{ icon("search") }}
</button>
{% endif %}
{#
Everything else about this chat, behind one button.
The bar carried eight controls, which is a toolbar nobody can read at
a glance. What is left in it is what you reach for while writing --
the model, and the two panels. These three are occasional, and every
one of them is also a slash command now.
#}
{% if chat %}
<div class="picker" data-picker>
<button class="btn btn--icon" type="button" data-picker-toggle
aria-haspopup="menu" aria-expanded="false"
aria-label="More" title="More">
{{ icon("dots") }}
</button>
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
hidden aria-label="More">
{% if chat.temporary %}
<button class="picker__option" type="button" role="menuitem"
hx-post="/api/chats/{{ chat.id }}/keep" hx-swap="none">
{{ icon("pin", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">Keep this chat</span>
<span class="picker__option-note">
Temporary — removed 24 hours after the last message
</span>
</span>
</button>
{% endif %}
{% if can.get("chat.system_prompt") or can.get("chat.params") %}
<button class="picker__option" type="button" role="menuitem"
data-toggle="#chat-settings">
{{ icon("sliders", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">Chat settings</span>
<span class="picker__option-note">Prompt, knowledge, sampling</span>
</span>
</button>
{% endif %}
{% if messages %}
{# Runs the same code as /compact rather than posting itself. Two
implementations meant a spinner on neither and the endpoint's
error messages reaching nobody. #}
<button class="picker__option" type="button" role="menuitem"
onclick="window.lembasCommands &amp;&amp; window.lembasCommands.run('compact')">
{{ icon("archive", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">Compact</span>
<span class="picker__option-note">
Summarise the earlier turns so they stop costing context
</span>
</span>
</button>
{% endif %}
</div>
</div>
{% endif %}
</div>
</header>
{% if chat and (can.get("chat.system_prompt") or can.get("chat.params")) %}
{# Collapsed by default: per-chat overrides, not everyday controls. Each
field saves on change, so there is no half-applied state. #}
<section class="panel" id="chat-settings" hidden>
<div class="panel__inner">
{% if current_model and current_model.description %}
<p class="panel__note">{{ current_model.description }}</p>
{% endif %}
{% if can.get("chat.system_prompt") %}
<div class="field">
<label class="field__label" for="system-prompt">System prompt</label>
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="3"
placeholder="{{ inherited_prompt or 'Instructions that apply to every message in this chat.' }}"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
hx-trigger="change">{{ chat.system_prompt }}</textarea>
<p class="field__hint">
{% if inherited_prompt %}
Leave empty to use the {{ inherited_from }} prompt shown above.
{% else %}
Overrides the model and instance prompts for this chat only.
{% endif %}
</p>
</div>
{% endif %}
{% if knowledge_bases %}
{# Which bases this chat draws on. None ticked means everything you can
see, which is what a chat with nothing chosen should do. #}
<div class="field">
<label class="field__label">Knowledge</label>
<form hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
{# Always submitted, so unticking the last box still says something.
An absent checkbox carries no signal of its own. #}
<input type="hidden" name="knowledge_base_ids" value="">
<div class="checkbox-row">
{% for base in knowledge_bases %}
<label class="checkbox">
<input type="checkbox" name="knowledge_base_ids" value="{{ base.id }}"
{{ 'checked' if base.id in attached_base_ids }}>
<span>{{ base.name }}</span>
</label>
{% endfor %}
</div>
</form>
<p class="field__hint">
{% if attached_base_ids %}
Searches in this chat are limited to what is ticked.
{% else %}
Nothing ticked, so this chat can search everything in your
<a href="/library/knowledge">library</a>.
{% endif %}
</p>
</div>
{% endif %}
{% if can.get("chat.params") %}
<div class="grid grid--3">
<div class="field">
<label class="field__label" for="temperature">Temperature</label>
<input class="input" id="temperature" name="temperature" type="number"
min="0" max="2" step="0.05" placeholder="default"
value="{{ chat.params_json.get('temperature') if chat.params_json.get('temperature') is not none else '' }}"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
</div>
<div class="field">
<label class="field__label" for="top-p">Top-p</label>
<input class="input" id="top-p" name="top_p" type="number"
min="0" max="1" step="0.05" placeholder="default"
value="{{ chat.params_json.get('top_p') if chat.params_json.get('top_p') is not none else '' }}"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
</div>
<div class="field">
<label class="field__label" for="max-tokens">Max tokens</label>
<input class="input" id="max-tokens" name="max_tokens" type="number"
min="1" step="1" placeholder="default"
value="{{ chat.params_json.get('max_tokens') if chat.params_json.get('max_tokens') is not none else '' }}"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
</div>
</div>
<p class="field__hint">
Leave a field empty to let the provider decide. Values outside the
allowed range are ignored rather than clamped.
</p>
{% endif %}
</div>
</section>
{% endif %}
{% if not models %}
{# Every fresh install lands here, so it points at the fix. #}
<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>
{# Only on a chat that does not exist yet. An empty chat someone
opened on purpose already has a model and a prompt chosen. #}
{% if not chat and suggestions %}
<div class="suggestions">
{% for suggestion in suggestions %}
<button class="suggestion" type="button"
data-suggestion="{{ suggestion.prompt }}">
<span class="suggestion__name">{{ suggestion.name }}</span>
{% if suggestion.description %}
<span class="suggestion__note">{{ suggestion.description }}</span>
{% endif %}
</button>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{# The same include the rewind response returns, so the conversation
is described in one place. Markdown was rendered server-side in
pages.py, keyed by message id. #}
{% include "chat/_thread.html" %}
</div>
</div>
{% include "chat/_composer.html" %}
{% endif %}
</main>
{# Third and fourth children of .shell, mirroring the sidebar opposite. The
terminal comes first so it sits to the left of the inspector. #}
{% if terminal_enabled %}
{% include "chat/_terminal.html" %}
{% endif %}
{% if chat and user.is_admin %}
{% include "chat/_inspector.html" %}
{% endif %}
</div>
{% endblock %}
{% block scripts %}
{% if terminal_enabled %}
{# Only where it can be used. xterm is nearly three times everything else
vendored, so a plain chat must never load it. #}
<script src="{{ url_for('static', path='vendor/xterm.js') }}" defer></script>
<script src="{{ url_for('static', path='vendor/xterm-addon-fit.js') }}" defer></script>
<script src="{{ url_for('static', path='js/terminal.js') }}" defer></script>
{% endif %}
{% endblock %}