193 lines
7.2 KiB
HTML
193 lines
7.2 KiB
HTML
{% extends "layout.html" %}
|
|
{% block title %}{{ host_bot.name }} - chat{% endblock %}
|
|
{% block content %}
|
|
<div class="chat-shell" data-chat-id="{{ chat.id }}"
|
|
hx-ext="sse"
|
|
sse-connect="/chats/{{ chat.id }}/events">
|
|
<header class="chat-header">
|
|
<h1>{{ host_bot.name }}</h1>
|
|
<div class="chat-meta muted">{{ chat.time }}</div>
|
|
<button class="drawer-toggle" type="button" aria-controls="drawer" aria-expanded="false">Drawer</button>
|
|
</header>
|
|
|
|
<section class="timeline" id="timeline"
|
|
sse-swap="turn_html"
|
|
hx-swap="beforeend">
|
|
{% if not turns %}
|
|
<p class="muted">No turns yet. Start typing below.</p>
|
|
{% else %}
|
|
{% for turn in turns %}
|
|
<div{% if turn.event_id is not none %} id="turn-{{ turn.event_id }}"{% endif %} class="turn turn-{{ turn.role }}">
|
|
<strong>{{ turn.speaker }}</strong>
|
|
{{ turn.text|render_prose|safe }}
|
|
</div>
|
|
{% endfor %}
|
|
{% endif %}
|
|
</section>
|
|
|
|
<form class="turn-input" method="post" action="/chats/{{ chat.id }}/turns">
|
|
<textarea name="prose" rows="3" placeholder="What do you say or do?" required></textarea>
|
|
<button type="submit">Send</button>
|
|
</form>
|
|
|
|
<aside class="drawer" id="drawer" hidden
|
|
hx-get="/chats/{{ chat.id }}/drawer"
|
|
hx-trigger="revealed"
|
|
hx-swap="innerHTML">
|
|
<p class="muted">Loading drawer…</p>
|
|
</aside>
|
|
</div>
|
|
<script>
|
|
document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => {
|
|
const drawer = document.getElementById('drawer');
|
|
const isHidden = drawer.hasAttribute('hidden');
|
|
if (isHidden) drawer.removeAttribute('hidden');
|
|
else drawer.setAttribute('hidden', '');
|
|
e.target.setAttribute('aria-expanded', String(isHidden));
|
|
});
|
|
</script>
|
|
<script>
|
|
// Streaming UX (T34): typing indicator, Stop button, send-lock,
|
|
// disconnect banner. Listens to the existing HTMX SSE channel for
|
|
// `token` (per-chunk) and `turn_html` (final swap) events. The
|
|
// mid-stream disconnect path is server-side: ``request.is_disconnected()``
|
|
// in T19 commits truncated; this script just shows the banner when
|
|
// the SSE EventSource fires `error` after the connection drops.
|
|
(function () {
|
|
const shell = document.querySelector('.chat-shell');
|
|
if (!shell) return;
|
|
const chatId = shell.dataset.chatId;
|
|
const form = shell.querySelector('.turn-input');
|
|
if (!form) return;
|
|
const textarea = form.querySelector('textarea[name="prose"]');
|
|
const sendBtn = form.querySelector('button[type="submit"]');
|
|
const timeline = document.getElementById('timeline');
|
|
|
|
let isStreaming = false;
|
|
let typingEl = null;
|
|
|
|
function ensureTypingEl() {
|
|
if (typingEl) return typingEl;
|
|
typingEl = document.createElement('div');
|
|
typingEl.className = 'turn turn-bot streaming';
|
|
typingEl.innerHTML = '<strong>...</strong><p class="streaming-text"></p>';
|
|
timeline.appendChild(typingEl);
|
|
return typingEl;
|
|
}
|
|
|
|
function unlock() {
|
|
isStreaming = false;
|
|
if (sendBtn) sendBtn.disabled = false;
|
|
if (textarea) {
|
|
textarea.readOnly = false;
|
|
textarea.value = '';
|
|
textarea.focus();
|
|
}
|
|
const stop = shell.querySelector('.stop-streaming');
|
|
if (stop) stop.remove();
|
|
}
|
|
|
|
function showBanner(msg) {
|
|
let banner = shell.querySelector('.connection-lost');
|
|
if (banner) return;
|
|
banner = document.createElement('div');
|
|
banner.className = 'connection-lost error';
|
|
banner.textContent = msg;
|
|
form.parentElement.insertBefore(banner, form);
|
|
}
|
|
|
|
// HTMX SSE extension dispatches `htmx:sseMessage` with detail.type
|
|
// (event name) and detail.data (payload string).
|
|
shell.addEventListener('htmx:sseMessage', (e) => {
|
|
const evt = e.detail.type;
|
|
const data = e.detail.data;
|
|
if (evt === 'token' && isStreaming) {
|
|
let parsed;
|
|
try { parsed = JSON.parse(data); } catch (_) { return; }
|
|
const el = ensureTypingEl();
|
|
el.querySelector('.streaming-text').textContent += (parsed.text || '');
|
|
} else if (evt === 'turn_html') {
|
|
// The server already pushes the final HTML via sse-swap on the
|
|
// timeline element; we just remove the typing placeholder and
|
|
// unlock the input. (Don't replace innerHTML here — HTMX has
|
|
// already done the append by the time this fires.)
|
|
if (typingEl) {
|
|
typingEl.remove();
|
|
typingEl = null;
|
|
}
|
|
unlock();
|
|
}
|
|
});
|
|
|
|
// T86: live-swap regenerated turns. The backend (chat/services/
|
|
// regenerate.py) broadcasts a ``turn_html_replace`` SSE frame after
|
|
// appending the new assistant_turn — JSON payload of shape
|
|
// ``{data: <html>, turn_id: <new_id>, supersedes_id: <old_id>}``.
|
|
// We replace the prior turn's DOM node in-place when we can locate
|
|
// it by id, otherwise fall back to appending so a tab opened mid-
|
|
// regenerate still shows the new turn. The renderer
|
|
// (chat/web/render.py::render_turn_html) and the Jinja loop above
|
|
// both stamp ``id="turn-<event_id>"`` on each turn DIV, so the
|
|
// primary in-place swap path is the live one — the append fallback
|
|
// only kicks in when a tab opened AFTER the regenerate started (no
|
|
// prior turn DOM node to replace).
|
|
shell.addEventListener('htmx:sseMessage', (e) => {
|
|
if (e.detail.type !== 'turn_html_replace') return;
|
|
let data;
|
|
try { data = JSON.parse(e.detail.data); } catch (_) { return; }
|
|
const html = (data && data.data) || '';
|
|
const trimmed = html.trim();
|
|
if (!trimmed) return;
|
|
const oldNode = document.getElementById('turn-' + data.supersedes_id);
|
|
if (oldNode) {
|
|
const tmpl = document.createElement('template');
|
|
tmpl.innerHTML = trimmed;
|
|
const newNode = tmpl.content.firstChild;
|
|
if (newNode) oldNode.replaceWith(newNode);
|
|
} else {
|
|
// Fallback: append if the prior turn isn't in the DOM (e.g. user
|
|
// opened the tab AFTER the regenerate started, or the renderer
|
|
// hasn't yet stamped per-turn ids — see comment above).
|
|
timeline.insertAdjacentHTML('beforeend', trimmed);
|
|
}
|
|
});
|
|
|
|
// SSE connection lost — show a banner and unlock so the user can
|
|
// retry. The server commits the partial as truncated when its
|
|
// request.is_disconnected() poll trips (T19).
|
|
shell.addEventListener('htmx:sseError', () => {
|
|
if (isStreaming) {
|
|
showBanner('connection lost — partial response saved');
|
|
unlock();
|
|
}
|
|
});
|
|
|
|
form.addEventListener('submit', () => {
|
|
isStreaming = true;
|
|
if (sendBtn) sendBtn.disabled = true;
|
|
// readOnly (not disabled) — disabled fields are excluded from the
|
|
// form submission, which would send prose="" and trigger the
|
|
// server's empty-prose 400.
|
|
if (textarea) textarea.readOnly = true;
|
|
if (!shell.querySelector('.stop-streaming')) {
|
|
const stopBtn = document.createElement('button');
|
|
stopBtn.type = 'button';
|
|
stopBtn.className = 'stop-streaming btn';
|
|
stopBtn.textContent = 'Stop';
|
|
stopBtn.addEventListener('click', async () => {
|
|
try {
|
|
await fetch('/chats/' + encodeURIComponent(chatId) + '/turns/cancel', {
|
|
method: 'POST',
|
|
});
|
|
} catch (_) {
|
|
// Network error on cancel is non-fatal — server will time out
|
|
// its own stream eventually and commit truncated.
|
|
}
|
|
});
|
|
form.parentElement.insertBefore(stopBtn, form);
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|