ae384fe618
- tts.py: replace piper subprocess with HTTP POST to madcat-tts /v1/audio/speech (chatterbox voice cloning) - chat.js: replace whisper server upload with browser Web Speech API (webkitSpeechRecognition) - chat.css: style persona picker — appearance:none select, themed with CSS vars, mobile responsive - main.py: default TTS voice → bt7274-en
475 lines
13 KiB
JavaScript
475 lines
13 KiB
JavaScript
/* 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();
|