/* chat.saiden.dev — frontend * * Minimal vanilla JS. WS connects to /ws, sends {content}, receives * {role, delta, done} chunks. Streams into a typewriter queue. * * Design rules (see UI-PLAN.md): * - Speaker labels only appear when speaker changes from the previous message. * - Pilot's outbound messages render instantly. BT's stream via typewriter. * - One coral pulsing dot while waiting. Cursor fades when stream completes. */ const TYPEWRITER_MS = 22; // ~45 chars/sec const SCROLL_EASE_MS = 600; const $ = (sel) => document.querySelector(sel); const $conversation = $('#conversation'); const $form = $('#prompt-form'); const $input = $('#prompt-input'); let ws = null; let connectAttempts = 0; let lastSpeaker = null; // 'user' | 'bt' | 'system' | null let currentBtBody = null; // active streaming .msg__body element let queue = []; let draining = false; // ---------- helpers ---------- function speakerLabel(role) { if (role === 'user') return window.__pilotName || 'Pilot'; if (role === 'bt') return window.__personaName || 'BT'; if (role === 'calibration') return '—'; return 'channel'; } function makeMsg(role) { const repeat = role === lastSpeaker; const msg = document.createElement('article'); msg.className = `msg msg--${role}`; if (repeat) msg.setAttribute('data-hide-label', 'true'); const label = document.createElement('div'); label.className = 'msg__label'; label.textContent = speakerLabel(role); msg.appendChild(label); const body = document.createElement('div'); body.className = 'msg__body'; msg.appendChild(body); $conversation.appendChild(msg); lastSpeaker = role; scrollToBottom(); return body; } function smoothScrollTo(target) { const start = window.scrollY; const dist = target - start; const t0 = performance.now(); function step(now) { const t = Math.min(1, (now - t0) / SCROLL_EASE_MS); const eased = 1 - Math.pow(1 - t, 3); window.scrollTo(0, start + dist * eased); if (t < 1) requestAnimationFrame(step); } requestAnimationFrame(step); } function scrollToBottom() { const target = document.documentElement.scrollHeight - window.innerHeight; smoothScrollTo(target); } function showThinking() { removeThinking(); const dot = document.createElement('div'); dot.className = 'thinking'; dot.id = 'thinking'; $conversation.appendChild(dot); scrollToBottom(); } function removeThinking() { const t = document.getElementById('thinking'); if (t) t.remove(); } // ---------- typewriter ---------- function enqueue(text) { for (const ch of text) queue.push(ch); if (!draining) drain(); } async function drain() { if (!currentBtBody) return; draining = true; while (queue.length) { currentBtBody.textContent += queue.shift(); if (Math.random() < 0.04) scrollToBottom(); await sleep(TYPEWRITER_MS); } draining = false; } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function finishBt() { // wait for the queue to drain before adding caret const tick = () => { if (draining || queue.length) { setTimeout(tick, 30); return; } if (!currentBtBody) return; const caret = document.createElement('span'); caret.className = 'caret'; currentBtBody.appendChild(caret); scrollToBottom(); setTimeout(() => caret.remove(), 900); currentBtBody = null; }; tick(); } // ---------- ws ---------- function connect() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${proto}//${location.host}/ws`); ws.addEventListener('open', () => { connectAttempts = 0; }); ws.addEventListener('message', (e) => { let msg; try { msg = JSON.parse(e.data); } catch { return; } handleMessage(msg); }); ws.addEventListener('close', (e) => { if (e.code === 4401) { // session expired — say so softly, but DO NOT navigate the page away. // The Pilot decides when to refresh. const body = makeMsg('system'); body.textContent = 'session lost — refresh when ready'; return; } // try a gentle reconnect, with a hard cap so we don't spam forever connectAttempts++; if (connectAttempts > 6) return; const delay = Math.min(8000, 600 * connectAttempts); setTimeout(connect, delay); }); } function handleMessage(msg) { if (msg.role === 'system') { const body = makeMsg('system'); body.textContent = msg.content || ''; return; } if (msg.role === 'calibration') { removeThinking(); const body = makeMsg('calibration'); body.textContent = msg.content || ''; if (Array.isArray(msg.choices) && msg.choices.length) { renderChoices(body.parentElement, msg.choices); } return; } if (msg.role === 'calibration_done') { // Transition in place — no reload. if (msg.persona_name) window.__personaName = msg.persona_name; if (msg.operator_name) window.__pilotName = msg.operator_name; if (msg.ui_palette) document.body.setAttribute('data-palette', msg.ui_palette); if (msg.ui_typography) document.body.setAttribute('data-typography', msg.ui_typography); if (msg.ui_density) document.body.setAttribute('data-density', msg.ui_density); if (msg.ui_labels) document.body.setAttribute('data-labels', msg.ui_labels); if (msg.persona_name) { $input.setAttribute('placeholder', `speak to ${msg.persona_name}`); } return; } if (msg.role === 'assistant') { if (!currentBtBody) { removeThinking(); currentBtBody = makeMsg('bt'); } if (msg.delta) enqueue(msg.delta); if (msg.done) finishBt(); } if (msg.role === 'audio' && msg.data) { playAudio(msg.mime || 'audio/wav', msg.data); } } // ---------- audio ---------- let currentAudio = null; function playAudio(mime, b64) { // stop any in-flight playback first — last word wins if (currentAudio) { try { currentAudio.pause(); } catch {} currentAudio = null; } const audio = new Audio(`data:${mime};base64,${b64}`); audio.volume = 0.9; audio.play().catch(err => { // autoplay may be blocked until first user gesture — gracefully degrade console.warn('audio autoplay blocked:', err.message); }); currentAudio = audio; } // ---------- form ---------- $form.addEventListener('submit', (e) => { e.preventDefault(); const text = $input.value.trim(); if (!text) return; if (!ws || ws.readyState !== WebSocket.OPEN) return; const body = makeMsg('user'); body.textContent = text; ws.send(JSON.stringify({ content: text })); $input.value = ''; showThinking(); }); // Cmd+K / Ctrl+K to refocus input document.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); $input.focus(); } }); // ---------- voice input (Web Speech API) ---------- const $mic = document.getElementById('mic-button'); const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; let recognition = null; let recording = false; // Disable mic if browser lacks support if (!SpeechRecognition) { console.warn('Web Speech API not supported — mic disabled'); $mic.disabled = true; $mic.title = 'Speech recognition not supported in this browser'; } function startRecording() { if (!SpeechRecognition) return; if (recognition) { // stale instance — tear it down first try { recognition.abort(); } catch {} recognition = null; } recognition = new SpeechRecognition(); recognition.continuous = false; recognition.interimResults = true; recognition.lang = 'en-US'; recognition.addEventListener('result', (e) => { // Build transcript from all results (interim + final) let transcript = ''; for (let i = 0; i < e.results.length; i++) { transcript += e.results[i][0].transcript; } $input.value = transcript; }); recognition.addEventListener('end', () => { recording = false; $mic.classList.remove('recording'); recognition = null; // Auto-send if we got text const text = $input.value.trim(); if (text) { $form.dispatchEvent(new Event('submit', { cancelable: true })); } else { flashMicEmpty(); } }); recognition.addEventListener('error', (e) => { recording = false; $mic.classList.remove('recording'); recognition = null; // 'no-speech' and 'aborted' are expected — not worth alarming the user if (e.error === 'no-speech') { flashMicEmpty(); } else if (e.error !== 'aborted') { console.warn('speech recognition error:', e.error); flashMicEmpty(); } }); try { recognition.start(); recording = true; $mic.classList.add('recording'); } catch (err) { console.warn('speech recognition failed to start:', err.message); recording = false; } } function stopRecording() { if (!recognition) return; try { recognition.stop(); } catch {} // 'end' event handler cleans up recording state and sends } function flashMicEmpty() { $mic.classList.add('empty'); setTimeout(() => $mic.classList.remove('empty'), 700); } // ---------- calibration choice tiles ---------- function renderChoices(parentEl, choices) { const wrap = document.createElement('div'); wrap.className = 'choices'; for (const c of choices) { const tile = document.createElement('button'); tile.type = 'button'; tile.className = 'choice'; if (c.value === '__other__') tile.classList.add('choice--other'); if (c.icon) { const icon = document.createElement('span'); icon.className = 'choice__icon'; icon.textContent = c.icon; tile.appendChild(icon); } const label = document.createElement('span'); label.className = 'choice__label'; label.textContent = c.label; tile.appendChild(label); tile.addEventListener('click', () => { // disable all tiles in this group wrap.querySelectorAll('.choice').forEach(b => b.disabled = true); tile.classList.add('choice--selected'); if (c.value === '__other__') { // remove tiles, focus input so the operator types freely wrap.remove(); $input.focus(); return; } sendChoice(c.label, c.value); // collapse tiles into a faint chosen tag setTimeout(() => wrap.remove(), 200); }); wrap.appendChild(tile); } parentEl.appendChild(wrap); scrollToBottom(); } function sendChoice(label, value) { if (!ws || ws.readyState !== WebSocket.OPEN) return; // Render as a "Pilot" message using the human-readable label const body = makeMsg('user'); body.textContent = label; // Send the machine value to the server ws.send(JSON.stringify({ content: value })); showThinking(); } function toggleMic() { if (recording) stopRecording(); else startRecording(); } $mic.addEventListener('click', toggleMic); // Hold-space-to-talk when input is NOT focused (so typing space still works) document.addEventListener('keydown', (e) => { if (e.code !== 'Space') return; if (document.activeElement === $input) return; if (e.repeat) return; e.preventDefault(); if (!recording) startRecording(); }); document.addEventListener('keyup', (e) => { if (e.code !== 'Space') return; if (recording) stopRecording(); }); // ---------- recalibrate ---------- const $recal = document.getElementById('recalibrate-btn'); if ($recal) { $recal.addEventListener('click', async () => { if ($recal.disabled) return; $recal.disabled = true; $recal.textContent = 'resetting…'; try { const resp = await fetch('/api/recalibrate', { method: 'POST' }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); // explicit reset is user-driven — reload is appropriate here location.reload(); } catch (err) { console.warn('recalibrate failed:', err.message); $recal.textContent = 'recalibrate'; $recal.disabled = false; } }); } // ---------- persona switcher ---------- const $personaSelect = document.getElementById('persona-select'); const $personaStatus = document.getElementById('persona-status'); if ($personaSelect) { // Set initial selection from server-rendered bound slug if (window.__boundSlug) { $personaSelect.value = window.__boundSlug; } $personaSelect.addEventListener('change', async () => { const slug = $personaSelect.value; if (!slug) return; // "— default —" selected $personaSelect.disabled = true; if ($personaStatus) $personaStatus.textContent = 'binding…'; try { const resp = await fetch('/api/persona', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug }), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${resp.status}`); } const data = await resp.json(); // Update display name in UI if ($personaStatus) $personaStatus.textContent = data.display || slug; // Update the speaker label for subsequent BT messages if (data.display) window.__personaName = data.display; // Subtle system message in conversation so the switch is visible const body = makeMsg('system'); body.textContent = `persona bound → ${data.display || slug}`; } catch (err) { console.warn('persona bind failed:', err.message); if ($personaStatus) $personaStatus.textContent = 'bind failed'; // revert select to previous value $personaSelect.value = window.__boundSlug || ''; } finally { $personaSelect.disabled = false; } }); } // ---------- init ---------- connect(); $input.focus();