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>
452 lines
21 KiB
HTML
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 && 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 %}
|