a63723713f
The terminal and the canvas both needed a Chat, so they were missing from the one screen where you are choosing which machine to work on. A draft is the smallest thing that fixes it: an id, and the three facts behind it. The trick is that a draft resolves to a *transient* Chat -- constructed, never added to a session. `canvas.agent_ready`, `_executor`, `_load_agent`, `_save_agent` and `agent_session.resolve` read exactly four attributes between them and none of them queries or writes the row, so all of it works unchanged and nothing had to learn what a draft is. Proven against a real sshd rather than a stub: a transient chat opens and saves a project file over the same SFTP path a real one uses, and the database stays empty throughout. Chats are still created lazily. A draft is not a chat and never becomes one; when the first prompt makes the real one, the shell is re-keyed into it and the open tabs are copied across. `terminal.rekey` moves the registry key *and* `session.chat_id`, because close_for_profile, close_for_owner and the reaper all pop by the field -- a stale one would leave a dead session that `get` keeps handing out. The shell is only adopted when its profile and directory match the chat as finally resolved, since `_new_chat` settles an empty directory to the connection's own; otherwise it is left alone rather than transplanted onto a chat that says it runs elsewhere. Two canvas sources are refused on a draft, by name, and one of them is a hole rather than an inconvenience. `_load_file` authorises with `attachment.chat_id != chat.id`, and an upload made on the new-chat screen is stored with `chat_id=None` -- so a draft whose chat carried no id would make that comparison `None != None`, which is False, and open every unclaimed attachment its owner has. `as_chat` does set an id, so it already fails; the refusal is stated anyway, because a guarantee that lives in an id-shaped coincidence is one the next change breaks without noticing. Adoption needed almost no JavaScript: start_chat already answers with HX-Redirect, so the page reloads and the canvas adopts by construction while the terminal reconnects to the re-keyed session and replays its scrollback -- the "a reload is indistinguishable from a second tab" property working for us. What re-points them mid-screen is a `lembas:agent-target` event, dispatched from `setDir` and the connection select because assigning to a hidden field's value fires nothing on its own. Driven under a DOM stub before committing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
367 lines
16 KiB
HTML
367 lines
16 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>
|
|
{#
|
|
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="Rename chat" data-prompt-field="title"
|
|
data-prompt-value="{{ chat.title }}"
|
|
aria-label="Rename chat" title="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="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 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="Canvas"
|
|
title="Open a file beside the conversation"
|
|
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="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 && 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>
|
|
|
|
{# 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="{{ url_for('static', path='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="{{ url_for('static', path='js/draft.js') }}" defer></script>
|
|
{% endif %}
|
|
{% if canvas_enabled %}
|
|
<script src="{{ url_for('static', path='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="{{ 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 %}
|