Files
LLeMbas/src/lembas/web/templates/chat/index.html
T
HomerandClaude Opus 5.5 e65ea90fe6 A temporary chat that keeps the model it was started on
The new-chat screen keeps its preselections in the query string, and the
three ways off it each rebuilt the URL with only their own key: the
Temporary button went to /chat?temporary=1, the model picker to
/chat?model=<id>, and /temp to /chat?temporary=1. So each undid the other,
and a temporary chat could only be started on the default model. The
folder from "New chat here" and the agent kind were dropped the same way.

chat_index now builds both URLs from one set of carried values and changes
only its own key; /temp keeps location.search when run on /chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:07:57 +00:00

452 lines
21 KiB
HTML

{% extends "base.html" %}
{% from "_macros.html" import icon, mark, model_avatar %}
{% block title %}{{ chat.title if chat else "New chat" }} - {{ brand.name }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
{% if terminal_enabled %}
<link rel="stylesheet" href="{{ asset('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">
{% include "partials/_sidebar_toggle.html" %}
<h1 class="topbar__title">
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
{#
Rename, where the name is. A themed dialog through `data-prompt`
rather than an inline field: the heading is in a flex row beside the
badges and the connection chip, and swapping it for a text box moves
all of them. The response is the same out-of-band pair the `done`
frame sends, so the sidebar row follows without a second request.
#}
{% if chat %}
<button class="btn btn--icon btn--sm topbar__rename" type="button"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
data-prompt="What should this chat be called?"
data-prompt-title="{{ t('Rename chat') }}" data-prompt-field="title"
data-prompt-value="{{ chat.title }}"
aria-label="{{ t('Rename chat') }}" title="{{ t('Rename chat') }}">
{{ icon("pencil", "icon--sm") }}
</button>
{% endif %}
{# 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="{{ t('Not listed in the sidebar, and removed 24 hours after the last message.') }}">{{ t("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. The URL is built in `chat_index` so it
keeps the chosen model, folder and kind.
#}
{% if not chat and can.get("chat.create") %}
<a class="btn btn--icon {{ 'is-active' if starting_temporary }}"
href="{{ temporary_toggle_url }}"
aria-label="{{ t('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 %}
{# Before a chat exists, both of these depend on a decision being made in
the browser -- the kind toggle and the connection select -- which the
server cannot see. So they render carrying `data-agent-only` and start
`hidden`; `ui.js` shows them when `lembas:agent-target` names a
connection and hides them again when it stops. On a chat that exists
the server's answer is complete and neither attribute appears. #}
{% if canvas_enabled %}
{# Nearest the conversation of the three, being the widest and the one
most likely to be open beside it. All three share one slot: at
1280px the sidebar plus two panels leaves about seventy pixels of
chat. #}
<button class="btn btn--icon" type="button" aria-label="{{ t('Canvas') }}"
title="{{ t('Open a file beside the conversation') }}"
{% if not chat %}data-agent-only hidden{% endif %}
aria-expanded="false" data-toggle="#canvas" data-toggle-group="side">
{{ icon("file-text") }}
</button>
{% 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="{{ t('Terminal') }}"
title="Open a shell on {{ agent_profile.name if agent_profile else 'this connection' }}"
{% if not chat %}data-agent-only hidden{% endif %}
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="{{ t('Inspect this chat') }}"
title="{{ t('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="{{ t('More') }}" title="{{ t('More') }}">
{{ icon("dots") }}
</button>
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
hidden aria-label="{{ t('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">{{ t("Keep this chat") }}</span>
<span class="picker__option-note">{{ t("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">{{ t("Chat settings") }}</span>
<span class="picker__option-note">{{ t("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">{{ t("Compact") }}</span>
<span class="picker__option-note">{{ t("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">{{ t("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">{{ t("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 crowd_available %}
{# Who else answers. Nothing ticked is every chat that has ever existed:
one model, answering on its own. #}
<div class="field">
<label class="field__label">{{ t("Crowd") }}</label>
<form hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
{# Always submitted, for the reason the bases above are. #}
<input type="hidden" name="crowd_model_ids" value="">
<div class="checkbox-row">
{% for model in crowd_available %}
<label class="checkbox">
<input type="checkbox" name="crowd_model_ids" value="{{ model.model_id }}"
{{ 'checked' if model.model_id in crowd_member_ids }}>
<span>{{ model.label }}</span>
</label>
{% endfor %}
</div>
</form>
<p class="field__hint">
{% if crowd_member_ids %}
{{ crowd_member_ids|length + 1 }} models answer each turn: this one
first, then the others, then back through them asking whether they
disagree, ending here.
<strong>That is {{ crowd_replies }} replies a turn</strong>, and up to
{{ crowd_rounds }} rounds of it.
{% else %}
Tick a model to have it answer after this one, then be asked whether
it disagrees. Useful for a second opinion; expensive, because each
one is a whole reply, and slow on one local endpoint because every
change of speaker loads a different model.
{% endif %}
</p>
{# Outside the two branches above, deliberately. A member whose model has
gone is filtered out of `crowd_member_ids`, so if it was the only one
this would fall into the "tick a model" branch and never mention the
row that is still there -- which is the one thing somebody needs to
know to tidy it up. #}
{% if crowd_skipped %}
<p class="field__hint">
Skipped, because you cannot reach {{ "them" if crowd_skipped|length > 1 else "it" }}
any more: <s>{{ crowd_skipped|join(", ") }}</s>. Untick to clear.
</p>
{% endif %}
</div>
{% endif %}
{% if can.get("chat.params") %}
<div class="grid grid--3">
<div class="field">
<label class="field__label" for="temperature">{{ t("Temperature") }}</label>
<input class="input" id="temperature" name="temperature" type="number"
min="0" max="2" step="0.05" placeholder="{{ t('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">{{ t("Top-p") }}</label>
<input class="input" id="top-p" name="top_p" type="number"
min="0" max="1" step="0.05" placeholder="{{ t('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">{{ t("Max tokens") }}</label>
<input class="input" id="max-tokens" name="max_tokens" type="number"
min="1" step="1" placeholder="{{ t('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">{{ t("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">{{ t("No models available") }}</h2>
<p class="empty__text">
{% if user.is_admin %}
Add an OpenAI-compatible connection and {{ brand.name }} 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">{{ t("What would you ask?") }}</h2>
<p class="empty__text">{{ brand.text.chat_empty }}</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>
{% if chat %}
{#
Where a reply that started outside a request arrives. A background job
finishing wakes the chat server-side and there is no channel to say so:
the only stream is per-message, opened by a bubble this page has not got.
See `GET /api/chats/{id}/tail`, and `app.js` for the cursor -- which is
read from the DOM on `htmx:configRequest`, not rendered here, because the
DOM is the honest answer to what this page already holds.
OUTSIDE `#thread`, not in it: a rewind and a compaction both swap
`chat/_thread.html` into that container with `innerHTML`, and a poller
living inside would be swapped away by the first one and never fire
again. OUTSIDE the composer's form as well, and it names its own target
regardless -- htmx inherits `hx-target`, the form carries `#thread`, and
the jobs chip has already demonstrated once what an unstated target does
to a transcript.
`hx-sync="this:drop"` because a poll fires whether or not the last one
has come back, and two answers computed from the same cursor are two
copies of one bubble.
#}
<div hidden id="thread-tail"
hx-get="/api/chats/{{ chat.id }}/tail"
hx-trigger="every 5s"
hx-target="#thread" hx-swap="beforeend"
hx-sync="this:drop"></div>
{% endif %}
{#
A task chat has no composer, and that is the section rather than a
restriction on it: a schedule's chat is written into by the schedule and
read by a person, so there is nothing for them to send. The controls
that *do* apply — run it now, pause it, edit it — take the row's place.
Suppressed by not including it, not by hiding it. `chat/_composer.html`
is the only thing that posts a message, so its absence is the guarantee;
a hidden one would still be a form anybody could post to.
#}
{% if chat and chat.kind == "task" %}
{% include "schedules/_strip.html" %}
{% else %}
{% include "chat/_composer.html" %}
{% endif %}
{% endif %}
</main>
{# The panels, mirroring the sidebar opposite, in the order they sit on
screen: the canvas nearest the conversation, then the terminal, then the
inspector. Only ever one of them is open -- see the toggle group. #}
{% if canvas_enabled %}
{% include "chat/_canvas.html" %}
{% endif %}
{% if terminal_enabled %}
{% include "chat/_terminal.html" %}
{% endif %}
{% if chat and user.is_admin %}
{% include "chat/_inspector.html" %}
{% endif %}
</div>
{% endblock %}
{% block scripts %}
{# Unconditional: every chat has a transcript, and this is what keeps a block
somebody opened open across the swaps that arrive twelve times a second. #}
<script src="{{ asset('js/steps.js') }}" defer></script>
{% if not chat and (canvas_enabled or terminal_enabled) %}
{# Only where there is no chat yet. It points both panels at a draft id for
whatever the composer has selected, and does nothing at all once a chat
exists -- which is every other page this block renders on. #}
<script src="{{ asset('js/draft.js') }}" defer></script>
{% endif %}
{% if canvas_enabled %}
<script src="{{ asset('js/canvas.js') }}" defer></script>
{% endif %}
{% 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="{{ asset('vendor/xterm.js') }}" defer></script>
<script src="{{ asset('vendor/xterm-addon-fit.js') }}" defer></script>
<script src="{{ asset('js/terminal.js') }}" defer></script>
{% endif %}
{% endblock %}