quantum-forms-dist/chat.js
2026-09-24 02:21:39 +00:00

195 lines
10 KiB
JavaScript

// chat.js — the SimpleX-style CHAT RENDERER for Quantum Forms. Each message the guest sends is a SUBMISSION through
// the quantum tunnel (POST /baas/sessions/{id}/submit). Backend→guest messages (agent questions, other users, system)
// arrive through the SAME tunnel over SSE (GET /baas/sessions/{id}/stream). A message IS a form submission; the chat
// is just one renderer over the same session pipe.
//
// Bidirectional over the quantum tunnel: guest→backend = POST-up (sealed submission); backend→guest = SSE-down.
const el = (id) => document.getElementById(id)
const msgs = el('msgs')
let sessionId = null, token = null, guestId = null, expiresAt = 0
function toast(t) { const e = el('toast'); e.textContent = t; e.style.display = 'block'; setTimeout(() => e.style.display = 'none', 2200) }
function addBubble(kind, text, meta, fileName) {
const b = document.createElement('div')
b.className = 'bubble ' + (kind === 'sent' ? 'sent' : kind === 'sys' ? 'sys' : 'recv')
if (kind === 'sys') { b.textContent = text; msgs.appendChild(b); msgs.scrollTop = msgs.scrollHeight; return b }
if (text) b.appendChild(document.createTextNode(text))
if (fileName) {
const chip = document.createElement('div'); chip.className = 'file-chip'; chip.textContent = '📎 ' + fileName
b.appendChild(chip)
}
if (meta) { const m = document.createElement('span'); m.className = 'meta'; m.textContent = meta; b.appendChild(m) }
msgs.appendChild(b); msgs.scrollTop = msgs.scrollHeight
return b
}
function hhmm() { const d = new Date(); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') }
// ── countdown timer (session is live until expires_at) ──
function startTimer() {
const tick = () => {
if (!expiresAt) return
const secs = expiresAt - Math.floor(Date.now() / 1000)
if (secs <= 0) { el('timer').textContent = 'session ended'; addBubble('sys', 'This session has ended.'); return }
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60
el('timer').textContent = 'live · ' + (h ? h + 'h ' : '') + m + 'm ' + String(s).padStart(2, '0') + 's left'
setTimeout(tick, 1000)
}
tick()
}
// ── join by code ──
async function join() {
const code = el('code').value.trim().toUpperCase()
if (!code) { toast('Enter the code'); return }
el('joinBtn').disabled = true
try {
const r = await fetch('/baas/sessions/join-by-code', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code })
})
if (!r.ok) { toast(r.status === 404 ? 'No open session for that code' : 'Could not join'); el('joinBtn').disabled = false; return }
const d = await r.json()
// ★ DISPATCHER: one entry point (the forms host). Route by the session's KIND to the right renderer —
// you never go to a different URL; you type the code here and the app loads the correct form type.
if (d.kind === 'qa-rounds' || d.kind === 'assembly') {
location.href = '/assembly.html?c=' + encodeURIComponent(code)
return
}
sessionId = d.session_id; token = d.subscribe_token; guestId = d.guest
expiresAt = d.expires_at || 0
el('gate').style.display = 'none'; el('chat').style.display = 'flex'
startTimer()
addBubble('sys', 'You joined the session. This chat is quantum-resistant end-to-end encrypted.')
// show the opening prompt ONCE at join (not on every reconnect — that was the repeating-message bug).
if (d.question) addBubble('recv', d.question, hhmm())
openStream()
} catch (e) { toast('Network error'); el('joinBtn').disabled = false }
}
// ── SSE: backend→guest messages through the tunnel ──
function openStream() {
if (typeof EventSource === 'undefined') return
const es = new EventSource(`/baas/sessions/${encodeURIComponent(sessionId)}/stream?token=${encodeURIComponent(token)}`)
// NOTE: the stream re-sends the opening 'question' on every (re)connect. We already showed it once at join,
// so we IGNORE the streamed 'question' event here — otherwise it repeats every reconnect (the "keeps telling me
// to send the file" bug). Only genuine 'message' events (team/agent replies) are shown live.
es.addEventListener('question', () => { /* intentionally ignored — shown once at join, not on every reconnect */ })
es.addEventListener('message', (e) => {
try { const d = JSON.parse(e.data); addBubble('recv', d.text || '', hhmm()) } catch (_) {}
})
es.addEventListener('closed', () => { addBubble('sys', 'The host closed this session.'); es.close() })
es.onerror = () => { /* the browser auto-reconnects EventSource */ }
}
// ── staged attachment (SimpleX pattern: picking a file STAGES it; send transmits it) ──
let stagedFile = null
function fmtSize(n) { return n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(0) + ' KB' : (n / 1048576).toFixed(1) + ' MB' }
function stageFile(file) {
if (!file) return
if (file.size > 40 * 1024 * 1024) { toast('File too large (40 MB max)'); return }
stagedFile = file
el('pvName').textContent = file.name
el('pvSize').textContent = fmtSize(file.size)
el('preview').style.display = 'flex'
}
function clearStaged() { stagedFile = null; el('preview').style.display = 'none'; el('fileInput').value = '' }
// ── send: transmits the typed message AND/OR the staged file through the tunnel (one action, like SimpleX) ──
async function send() {
const ta = el('input'); const text = ta.value.trim()
if (!text && !stagedFile) return
el('sendBtn').disabled = true
// send the file first (if staged), then the text — each is one submission through the tunnel.
try {
if (stagedFile) {
const file = stagedFile
// Signal-style: a bubble with a live progress ring + a cancel (✕) button while the raw bytes stream to Garage.
const bubble = addFileBubble(file.name)
const ok = await uploadWithProgress(file, bubble)
if (ok === 'cancelled') { bubble.remove(); toast('Upload cancelled'); el('sendBtn').disabled = false; return }
setBubbleDone(bubble, ok)
if (ok) clearStaged(); else { toast('File failed'); el('sendBtn').disabled = false; return }
}
if (text) {
const bubble = addBubble('sent', text, hhmm() + ' · sending…')
const ok = await submitPayload({ type: 'message', text })
if (bubble) { const m = bubble.querySelector('.meta'); if (m) m.textContent = ok ? hhmm() + ' · sent ✓' : hhmm() + ' · failed' }
if (ok) { ta.value = ''; autoGrow() } else toast('Failed to send')
}
} catch (e) { toast('Network error') } finally { el('sendBtn').disabled = false }
}
// ── Signal-style file bubble: a progress ring + % + a cancel (✕) button ──
function addFileBubble(name) {
const b = document.createElement('div')
b.className = 'bubble sent filebub'
b.innerHTML =
'<div class="fb-row">' +
'<svg class="fb-ring" viewBox="0 0 36 36"><circle class="fb-bg" cx="18" cy="18" r="15"/>' +
'<circle class="fb-fg" cx="18" cy="18" r="15"/></svg>' +
'<span class="fb-name"></span>' +
'<button class="fb-cancel" title="Cancel">✕</button>' +
'</div><span class="meta">' + hhmm() + ' · 0%</span>'
b.querySelector('.fb-name').textContent = '📎 ' + name
msgs.appendChild(b); msgs.scrollTop = msgs.scrollHeight
return b
}
function setBubbleProgress(b, pct) {
const circ = 2 * Math.PI * 15
const fg = b.querySelector('.fb-fg')
if (fg) { fg.style.strokeDasharray = circ; fg.style.strokeDashoffset = circ * (1 - pct / 100) }
const m = b.querySelector('.meta'); if (m) m.textContent = hhmm() + ' · ' + Math.round(pct) + '%'
}
function setBubbleDone(b, ok) {
const c = b.querySelector('.fb-cancel'); if (c) c.remove()
const m = b.querySelector('.meta'); if (m) m.textContent = hhmm() + (ok ? ' · sent ✓' : ' · failed')
if (ok) setBubbleProgress(b, 100)
}
// uploadWithProgress streams the raw file to /upload via XHR (real upload-progress events) + supports CANCEL.
function uploadWithProgress(file, bubble) {
return new Promise((resolve) => {
const q = `token=${encodeURIComponent(token)}&name=${encodeURIComponent(file.name)}&type=${encodeURIComponent(file.type || 'application/octet-stream')}`
const xhr = new XMLHttpRequest()
xhr.open('POST', `/baas/sessions/${encodeURIComponent(sessionId)}/upload?${q}`)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
xhr.upload.onprogress = (e) => { if (e.lengthComputable) setBubbleProgress(bubble, (e.loaded / e.total) * 100) }
xhr.onload = () => resolve(xhr.status >= 200 && xhr.status < 300)
xhr.onerror = () => resolve(false)
xhr.onabort = () => resolve('cancelled')
// the cancel (✕) button aborts the in-flight upload — like Signal's pause/cancel.
const cancelBtn = bubble.querySelector('.fb-cancel')
if (cancelBtn) cancelBtn.addEventListener('click', () => xhr.abort())
xhr.send(file)
})
}
// submitPayload posts ONE submission through the quantum tunnel; returns true on success.
async function submitPayload(payload) {
try {
const r = await fetch(`/baas/sessions/${encodeURIComponent(sessionId)}/submit?token=${encodeURIComponent(token)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sealed: false, payload })
})
if (r.status === 429) { toast('Slow down'); return false }
return r.ok
} catch (e) { return false }
}
// ── composer wiring ──
function autoGrow() { const ta = el('input'); ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 120) + 'px' }
el('joinBtn').addEventListener('click', join)
el('code').addEventListener('keydown', (e) => { if (e.key === 'Enter') join() })
el('sendBtn').addEventListener('click', send)
el('input').addEventListener('input', autoGrow)
el('input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } })
el('attachBtn').addEventListener('click', () => el('fileInput').click())
el('fileInput').addEventListener('change', (e) => { if (e.target.files[0]) stageFile(e.target.files[0]) }) // STAGE, don't send
el('pvCancel').addEventListener('click', clearStaged)
// prefill code from ?c=
const qs = new URLSearchParams(location.search)
if (qs.get('c')) el('code').value = qs.get('c')