chore: initial commit — chat-saiden web chat baseline

This commit is contained in:
marauder-actual
2026-05-29 13:47:34 +02:00
commit 96ba8f4b6e
28 changed files with 4852 additions and 0 deletions
+417
View File
@@ -0,0 +1,417 @@
/* 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();