/* 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 (whisper) ---------- const $mic = document.getElementById('mic-button'); let mediaRecorder = null; let recordedChunks = []; let recording = false; async function startRecording() { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); recordedChunks = []; // Prefer webm/opus if browser supports it (Chrome/FF). Safari may need fallback. const mimeTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', '']; const mime = mimeTypes.find(m => !m || MediaRecorder.isTypeSupported(m)) || ''; mediaRecorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined); mediaRecorder.addEventListener('dataavailable', (e) => { if (e.data && e.data.size > 0) recordedChunks.push(e.data); }); mediaRecorder.addEventListener('stop', () => { // release the mic immediately stream.getTracks().forEach(t => t.stop()); handleRecorded(); }); mediaRecorder.start(); recording = true; $mic.classList.add('recording'); } catch (err) { console.warn('microphone unavailable:', err.message); recording = false; } } function stopRecording() { if (!mediaRecorder || mediaRecorder.state === 'inactive') return; mediaRecorder.stop(); recording = false; $mic.classList.remove('recording'); $mic.classList.add('transcribing'); } async function handleRecorded() { if (!recordedChunks.length) { $mic.classList.remove('transcribing'); return; } const blob = new Blob(recordedChunks, { type: recordedChunks[0].type || 'audio/webm' }); const ext = (blob.type.split('/')[1] || 'webm').split(';')[0]; const form = new FormData(); form.append('audio', blob, `speech.${ext}`); try { const resp = await fetch('/api/transcribe', { method: 'POST', body: form }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const { text } = await resp.json(); if (text && text.trim()) { $input.value = text; // auto-send — Her vibe is "speak and she hears" $form.dispatchEvent(new Event('submit', { cancelable: true })); } else { flashMicEmpty(); } } catch (err) { console.warn('transcription failed:', err.message); flashMicEmpty(); } finally { $mic.classList.remove('transcribing'); } } function flashMicEmpty() { // brief visual hint that nothing was heard — no toast, no popup, just a flash $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; } }); } // ---------- init ---------- connect(); $input.focus();