Files
chat/chat/templates/chat.html
T

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&hellip;</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 %}