ec12c3a981
A folder was a name and nothing else -- and not even that, since PATCH could rename one and nothing in the interface ever called it. It now carries a description, a system prompt, and seeds for the model, the kind and the agent target, with a settings page behind the row. The prompt is a fourth rung on the ladder, chat > folder > model > instance, and it goes above the model deliberately: a model's prompt describes the model wherever it is used, a folder's describes this piece of work whichever model is pointed at it. It is read when a reply is built rather than copied when a chat is made, so editing it reaches the chats already there, and the walk up the parents is bounded and cycle-safe because it runs on the request path. `api/pages.py` mirrors the ladder for the settings panel and had to gain the same rung -- a panel naming the wrong source is worse than one naming none, because it is believed. The seeds fill in what the request left empty and nothing it filled in: the folder says what this work usually needs, the screen in front of somebody says what they want this time. `ssh_profile_id` is a plain string rather than a foreign key, for the reason `compacted_through_id` is, so it is validated on read. Getting *into* a folder needed fixing too. `/api/chats/start` has accepted a folder_id since folders existed and nothing ever sent one, so the only route in was to make the chat elsewhere and move it. There is a New chat here on the row now, and `?folder=` on the new-chat screen. Naming is a themed dialog, and deliberately not htmx's hx-prompt: htmx calls the browser's prompt() synchronously and only then fires htmx:prompt with the answer already in hand, so intercepting the event cannot supply a different one and the grey box appears anyway. `data-prompt` follows the data-confirm-button shape instead -- swallow the click, ask, write the answer into hx-vals, click again behind a guard. JSON.stringify rather than concatenation, or a folder called `"` produces hx-vals that does not parse and the rename silently does nothing. Driven under a DOM stub, and there is a test that no template brings hx-prompt back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
487 lines
24 KiB
HTML
487 lines
24 KiB
HTML
{% from "_macros.html" import icon %}
|
|
{#
|
|
The composer, used both inside an existing chat and on /chat where no chat
|
|
row exists yet.
|
|
|
|
The only difference is where it posts. With a chat, the turn is appended to
|
|
the thread in place; without one, /api/chats/start creates the chat and
|
|
redirects, and the reply streams on arrival because the page renders the
|
|
unfinished assistant message with its sse-connect. That is what stops an
|
|
opened-and-abandoned chat ever being written to the database.
|
|
|
|
The attachment chips live INSIDE this form on purpose. Each carries a hidden
|
|
file_ids input, and being inside the form is what gets them serialised with
|
|
the message. Keeping them outside and reaching for hx-include does not work:
|
|
that attribute only has an effect on the element issuing the request.
|
|
|
|
Layout: chips, then the text, then one toolbar row underneath carrying
|
|
everything that acts on the message. The kind selector and the connection
|
|
used to sit in a strip *above* the text, inside the same bordered card, where
|
|
they read as debris floating in the input rather than as controls. Below the
|
|
text they are in the same place as attach and send, which is where the hand
|
|
already is.
|
|
#}
|
|
{# The chat, the connection and the directory, where composer.js can read them
|
|
without parsing them back out of a URL. On a new chat the connection is
|
|
still being chosen, so the select and the hidden field win over these. #}
|
|
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}
|
|
{% if chat %}data-chat-id="{{ chat.id }}"{% endif %}
|
|
{% if chat and chat.ssh_profile_id %}data-profile-id="{{ chat.ssh_profile_id }}"
|
|
data-project-dir="{{ chat.project_dir }}"{% endif %}>
|
|
{% if can.get("files.upload") %}
|
|
{# Outside the form: it is only ever read by JavaScript, and inside it would
|
|
be submitted as an empty file part on every message.
|
|
|
|
Two inputs rather than one whose accept attribute is rewritten: changing
|
|
accept and then calling click() in the same tick is unreliable in Safari,
|
|
and two hidden inputs cost nothing. #}
|
|
<input class="visually-hidden" type="file" id="file-input" multiple
|
|
data-upload-url="/api/files{% if chat %}?chat_id={{ chat.id }}{% endif %}"
|
|
accept=".pdf,.txt,.md,.csv,.json,.py,.js,.ts,.rs,.go,.sh,.sql,.yaml,.yml,.toml,.log"
|
|
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
|
|
<input class="visually-hidden" type="file" id="image-input" multiple accept="image/*"
|
|
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
|
|
{% endif %}
|
|
|
|
<div class="composer__inner">
|
|
<form class="composer__form"
|
|
{% if chat %}
|
|
hx-post="/api/chats/{{ chat.id }}/messages"
|
|
hx-target="#thread" hx-swap="beforeend"
|
|
hx-on::after-request="if (event.detail.successful) {
|
|
this.reset();
|
|
document.getElementById('attachments').replaceChildren();
|
|
window.lembas.autosize(this.querySelector('textarea'));
|
|
window.lembas.scrollThread(true);
|
|
}"
|
|
{% else %}
|
|
hx-post="/api/chats/start" hx-swap="none"
|
|
{% endif %}>
|
|
|
|
<div class="composer__attachments" id="attachments"></div>
|
|
|
|
{% if not chat and current_model %}
|
|
<input type="hidden" name="model_id" value="{{ current_model.model_id }}">
|
|
{% endif %}
|
|
{% if not chat and starting_temporary %}
|
|
<input type="hidden" name="temporary" value="true">
|
|
{% endif %}
|
|
{% if not chat and starting_folder %}
|
|
{# `/api/chats/start` has accepted a folder_id since folders existed and
|
|
nothing ever sent one, so the only way into a folder was to make the
|
|
chat elsewhere and move it. This is "New chat here" arriving. #}
|
|
<input type="hidden" name="folder_id" value="{{ starting_folder.id }}">
|
|
{% endif %}
|
|
|
|
{#
|
|
The text, with a mirror behind it.
|
|
|
|
A textarea cannot style its own contents, so the mirror holds the same
|
|
text with every character transparent and contributes nothing but a
|
|
rounded rectangle behind each recognised token. The real text stays in
|
|
the textarea, where it is native and selectable -- the other way round,
|
|
showing the mirror's text and hiding the textarea's, means any style
|
|
drift renders as doubled or blurred glyphs instead of a rectangle a
|
|
pixel out of place.
|
|
|
|
composer.js fills it. Without JavaScript there is simply no mirror.
|
|
#}
|
|
<div class="composer__field">
|
|
<div class="composer__mirror" data-composer-mirror aria-hidden="true"></div>
|
|
<textarea class="composer__input" name="content" rows="1"
|
|
data-autosize data-max-height="320" data-composer-input
|
|
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
|
|
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
|
|
</div>
|
|
|
|
<div class="composer__toolbar">
|
|
<div class="composer__tools">
|
|
{% if can.get("files.upload") %}
|
|
{# A menu rather than the file picker straight away: there are four ways
|
|
to attach something now, and only one of them is a file on disk.
|
|
Uses the same picker machinery as the model chooser -- see ui.js. #}
|
|
<div class="picker picker--up" data-picker>
|
|
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
|
aria-haspopup="menu" aria-expanded="false"
|
|
aria-label="Attach" title="Attach">
|
|
{{ icon("attach") }}
|
|
</button>
|
|
|
|
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
|
|
hidden aria-label="Attach">
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="file">
|
|
{{ icon("attach", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">File</span>
|
|
<span class="picker__option-note">PDF, text, code</span>
|
|
</span>
|
|
</button>
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="image">
|
|
{{ icon("image", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">Image</span>
|
|
<span class="picker__option-note">Sent only to vision models</span>
|
|
</span>
|
|
</button>
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="link">
|
|
{{ icon("link", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">Link</span>
|
|
<span class="picker__option-note">Fetch a page and attach its text</span>
|
|
</span>
|
|
</button>
|
|
{% if can.get("library.use") %}
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="knowledge">
|
|
{{ icon("archive", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">Knowledge</span>
|
|
<span class="picker__option-note">From your library</span>
|
|
</span>
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{% endif %}
|
|
|
|
{#
|
|
What this chat may use.
|
|
|
|
This slot used to be an `@` button that inserted the character and
|
|
got out of the way -- which the `@` key already does, from the
|
|
keyboard, without a button. Typing `@` is untouched; composer.js
|
|
recognises the token on its own and knows nothing about this menu.
|
|
|
|
The rows are `<label>`s wrapping a checkbox and deliberately carry
|
|
no `role="menuitem"`: ui.js closes a picker when a menuitem is
|
|
clicked, which is right for an action menu and wrong for a list of
|
|
switches you want to set several of. That is the whole reason this
|
|
needs no JavaScript at all.
|
|
|
|
The verb is on the CHECKBOX, not on the label and not on a form: the
|
|
element carrying `name` has to be the element carrying the request,
|
|
which is what tests/conftest.py:control_named exists to pin.
|
|
|
|
Only on an existing chat -- there is no row to write to before one
|
|
exists, and a switch that went nowhere is worse than no switch.
|
|
#}
|
|
{% set has_scope = chat and (scope_families or scope_skills or scope_allow) %}
|
|
{% if has_scope or can.get("files.upload") %}
|
|
<div class="picker picker--up" data-picker>
|
|
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
|
aria-haspopup="menu" aria-expanded="false"
|
|
aria-label="{{ 'What this chat can use' if has_scope else 'Mention a file' }}"
|
|
title="{{ 'What this chat can use' if has_scope else 'Mention a file' }}">
|
|
{{ icon("sliders" if has_scope else "at") }}
|
|
</button>
|
|
|
|
<div class="picker__menu picker__menu--scope" data-picker-menu role="menu"
|
|
hidden aria-label="What this chat can use">
|
|
{% if has_scope %}
|
|
<p class="picker__lede">
|
|
Switched off here only. Everything is on unless you say otherwise.
|
|
</p>
|
|
{% endif %}
|
|
|
|
{% if scope_families %}
|
|
<p class="picker__group">Tools</p>
|
|
{% for family in scope_families %}
|
|
<label class="picker__option picker__option--toggle">
|
|
<input type="checkbox" name="on" value="true"
|
|
{{ 'checked' if family.on }}
|
|
hx-post="/api/chats/{{ chat.id }}/scope" hx-swap="none"
|
|
hx-vals='{"kind": "family", "name": "{{ family.gate }}"}'>
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">{{ family.label }}</span>
|
|
</span>
|
|
</label>
|
|
{% endfor %}
|
|
{% endif %}
|
|
|
|
{% if scope_skills %}
|
|
<p class="picker__group">Skills</p>
|
|
{% for skill in scope_skills %}
|
|
<label class="picker__option picker__option--toggle">
|
|
<input type="checkbox" name="on" value="true"
|
|
{{ 'checked' if skill.on }}
|
|
hx-post="/api/chats/{{ chat.id }}/scope" hx-swap="none"
|
|
hx-vals='{"kind": "skill", "name": "{{ skill.name }}"}'>
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">{{ skill.name }}</span>
|
|
{% if skill.description %}
|
|
<span class="picker__option-note">{{ skill.description }}</span>
|
|
{% endif %}
|
|
</span>
|
|
</label>
|
|
{% endfor %}
|
|
{% endif %}
|
|
|
|
{# What this chat has been told to stop asking about, one entry per
|
|
press of "Always allow this" on an approval card. Shown because
|
|
a standing permission nobody can see is one nobody can revoke --
|
|
and because the entry for a command is the exact command line,
|
|
which is worth being able to read back.
|
|
|
|
Clear swaps the whole block for nothing, so the row disappears.
|
|
htmx does not swap on a 204, which is why the route returns an
|
|
empty body. #}
|
|
{% if scope_allow %}
|
|
<div class="picker__allow">
|
|
<p class="picker__group">Always allowed here</p>
|
|
{% for entry in scope_allow %}
|
|
<p class="picker__allow-entry">{{ entry }}</p>
|
|
{% endfor %}
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
hx-post="/api/chats/{{ chat.id }}/allow/clear"
|
|
hx-target="closest .picker__allow" hx-swap="outerHTML">
|
|
{{ icon("trash", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">Ask me about these again</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# The affordance the `@` button used to be, kept as one row so
|
|
nothing is lost by replacing the button -- and it is what this
|
|
menu holds on a chat that does not exist yet, where there is no
|
|
scope to narrow.
|
|
|
|
This one DOES carry role="menuitem", unlike the switches above:
|
|
it is an action, so ui.js closing the picker after it is
|
|
exactly right. #}
|
|
{% if can.get("files.upload") %}
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-mention-open>
|
|
{{ icon("at", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">Mention a file or a document</span>
|
|
<span class="picker__option-note">Or just type @</span>
|
|
</span>
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{#
|
|
What this conversation is and where it runs.
|
|
|
|
On a new chat all of it is editable. On an existing one the kind, the
|
|
connection and the directory are fixed -- update_chat refuses them
|
|
with a 409, because a transcript whose earlier turns ran somewhere
|
|
else is not one conversation -- so they render as a read-only chip and
|
|
only the mode stays live. The mode is the exception on purpose: it
|
|
decides what gets asked about, not what the conversation is.
|
|
#}
|
|
{% if not chat and agent_profiles %}
|
|
<div class="composer__context" data-agent-picker>
|
|
{# Seeded from `?kind=`, which is how the sidebar's Agent side opens
|
|
this screen already on the right fork. `ui.js` reads the checked
|
|
radio when it wires the picker, so the hidden field and the
|
|
revealed connection follow from this and nothing else. #}
|
|
<input type="hidden" name="kind" value="{{ starting_kind | default('chat') }}"
|
|
id="chat-kind">
|
|
|
|
<div class="segmented" role="group" aria-label="Kind of chat">
|
|
<label class="segmented__option">
|
|
<input type="radio" name="kind_choice" value="chat"
|
|
{{ '' if starting_kind == 'agent' else 'checked' }}>
|
|
<span>{{ icon("chat", "icon--sm") }} Chat</span>
|
|
</label>
|
|
<label class="segmented__option">
|
|
<input type="radio" name="kind_choice" value="agent"
|
|
{{ 'checked' if starting_kind == 'agent' }}>
|
|
<span>{{ icon("bolt", "icon--sm") }} Agent</span>
|
|
</label>
|
|
</div>
|
|
|
|
<span class="composer__agent" data-agent-extra hidden>
|
|
<select class="select select--sm composer__connection"
|
|
name="ssh_profile_id" aria-label="Connection">
|
|
{% for profile in agent_profiles %}
|
|
<option value="{{ profile.id }}" data-dir="{{ profile.default_dir }}"
|
|
{{ 'disabled' if not profile.verified }}>
|
|
{{ profile.name }}{{ ' — not checked' if not profile.verified }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
|
|
{# A button and a hidden field, not a text box. The text box was
|
|
real and submitted, but unlabelled and squeezed to a few
|
|
characters by the select beside it, so it read as broken. A path
|
|
is also something you would rather find than spell. #}
|
|
<input type="hidden" name="project_dir" value="" data-dir-value>
|
|
<button class="btn btn--sm composer__dir" type="button" data-dir-browse
|
|
aria-label="Project directory"
|
|
title="Choose the directory this chat works in">
|
|
{{ icon("folder", "icon--sm") }}
|
|
<span class="composer__dir-path" data-dir-label>/</span>
|
|
</button>
|
|
|
|
{% if agent_modes %}
|
|
<select class="select select--sm" name="agent_mode" aria-label="Approval mode">
|
|
{% for value, label, hint in agent_modes %}
|
|
<option value="{{ value }}" title="{{ hint }}">{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% endif %}
|
|
</span>
|
|
</div>
|
|
|
|
{% elif chat and chat.kind == "agent" %}
|
|
{# Only the mode. The connection and the directory moved to the topbar:
|
|
they cannot change -- update_chat refuses both with a 409 -- so they
|
|
are facts about the chat rather than controls on the message, and
|
|
they were taking a slot in a row that has work to do.
|
|
|
|
Its own form: nesting one inside the composer's form is invalid HTML
|
|
and the browser drops the inner one.
|
|
|
|
The verb is on the select, not on that form. htmx binds a trigger to
|
|
the annotated element itself, and `change` fires here and bubbles
|
|
through this element's *ancestors* -- which a sibling form is not.
|
|
`form=` scopes the values, and only the values. #}
|
|
<div class="composer__context">
|
|
<select class="select select--sm" name="agent_mode" aria-label="Approval mode"
|
|
form="agent-mode-form"
|
|
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
|
|
{% for value, label, hint in agent_modes %}
|
|
<option value="{{ value }}" title="{{ hint }}"
|
|
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{#
|
|
How hard a reasoning model should think. Outside the agent branch
|
|
above, because it applies to any chat.
|
|
|
|
Only on a model an administrator has marked as **reasoning**: that
|
|
flag has existed since the beginning with no reader at all, and
|
|
offering the control everywhere would be offering a setting that does
|
|
nothing almost everywhere.
|
|
|
|
On an existing chat it writes on change, and needs the same treatment
|
|
the mode select gets: the verb on the control, `form=` for the values.
|
|
Before there is a chat there is nothing to PATCH, so it is an ordinary
|
|
field of the composer's own form and `_new_chat` reads it -- which is
|
|
what makes the setting choosable before the first prompt rather than
|
|
after it.
|
|
#}
|
|
{% if current_model and current_model.capabilities_json.get("reasoning") %}
|
|
<select class="select select--sm" name="reasoning_effort" data-effort
|
|
aria-label="Reasoning effort" title="How hard this model should think"
|
|
{% if chat %}
|
|
form="chat-params-form"
|
|
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
|
{% endif %}>
|
|
{#
|
|
It shows the level actually in force, never the word "default".
|
|
|
|
On an existing chat that is `resolved_effort`, which is the chat's
|
|
own value and nothing else -- `build_request` reads the same field,
|
|
so what is shown is what is sent, by construction. Before there is a
|
|
chat it is the model's configured level, which `_new_chat` seeds
|
|
onto the row, so the same holds.
|
|
|
|
"off" is a sentinel and NOT an empty value. `start_chat` declares
|
|
`reasoning_effort: str = Form("")`, so absent and empty are
|
|
indistinguishable there -- with `value=""` the reader would pick off
|
|
and silently get the model's default.
|
|
#}
|
|
{% set chosen = resolved_effort if chat
|
|
else (current_model.params_json or {}).get('reasoning_effort') %}
|
|
<option value="off" {{ 'selected' if chosen not in efforts }}>Effort: off</option>
|
|
{% for value in efforts %}
|
|
<option value="{{ value }}" {{ 'selected' if chosen == value }}>
|
|
Effort: {{ value }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% endif %}
|
|
|
|
<div class="composer__actions">
|
|
{% 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"
|
|
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>
|
|
</div>
|
|
</form>
|
|
|
|
{# Outside the composer's form, and referenced by the two selects' `form`
|
|
attributes above. These carry no htmx of their own: they exist so that
|
|
`Nt(e)` -- htmx's "which form do the values come from", which reads
|
|
`e.form` before falling back to `closest("form")` -- resolves to a form
|
|
holding exactly one control. Without them the PATCH would carry the
|
|
composer's `content`, `model_id` and `project_dir`, and `update_chat`
|
|
answers `project_dir` with a 409.
|
|
|
|
hx-patch and not hx-post: there is no POST for a chat, only PATCH, and
|
|
htmx shows nothing when a request 405s -- which is how these controls
|
|
spent the first half of their lives doing nothing. #}
|
|
{% if chat and chat.kind == "agent" %}
|
|
<form id="agent-mode-form"></form>
|
|
{% endif %}
|
|
{% if chat %}
|
|
<form id="chat-params-form"></form>
|
|
{% endif %}
|
|
|
|
<p class="composer__hint">
|
|
Enter to send, Shift+Enter for a new line.
|
|
<button class="composer__hint-link" type="button"
|
|
onclick="window.lembasCommands && window.lembasCommands.help()">
|
|
/ for commands
|
|
</button>,
|
|
@ for files.
|
|
{% if can.get("files.upload") %}
|
|
Drag files in, or paste an image.
|
|
{% if current_model and not current_model.capabilities_json.get("vision") %}
|
|
<strong>{{ current_model.label }} has no vision</strong>, so images
|
|
will not be sent — documents still will.
|
|
{% endif %}
|
|
{% endif %}
|
|
</p>
|
|
</div>
|
|
|
|
{% if can.get("files.upload") %}
|
|
<div class="dropzone-overlay" aria-hidden="true">
|
|
{{ icon("attach", "icon--lg") }}
|
|
<span>Drop to attach</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|