chore: initial commit — chat-saiden web chat baseline
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
/* chat.saiden.dev
|
||||
*
|
||||
* Default look: clean, generic, neutral chat. Inter, soft grey, no cursive.
|
||||
* Personality is the result of calibration, not the starting point.
|
||||
*
|
||||
* Once calibrated, the body gets data-* attributes:
|
||||
* data-palette = default | rose | morning | evening | sage | paper | ink
|
||||
* data-typography = sans | serif-warm | serif-formal | mixed-modern | mono
|
||||
* data-density = airy | normal | dense
|
||||
* data-labels = block | cursive | none | prefix
|
||||
*
|
||||
* Each combination is a different room.
|
||||
*/
|
||||
|
||||
/* ===================== DEFAULT TOKENS (neutral) ===================== */
|
||||
:root {
|
||||
/* surfaces */
|
||||
--bg: #f4f4f3;
|
||||
--bg-soft: #ececea;
|
||||
--surface: #e3e3e1;
|
||||
|
||||
/* text */
|
||||
--ink: #1f2024;
|
||||
--ink-muted: #6a6a6f;
|
||||
--ink-faint: #a8a8ac;
|
||||
|
||||
/* accents (low-saturation by default) */
|
||||
--coral: #6a6a6f; /* default accent is neutral grey */
|
||||
--red: #8a4a3e;
|
||||
--gold: #9a8460;
|
||||
|
||||
/* lines */
|
||||
--line: #d6d6d3;
|
||||
|
||||
/* font stacks (default = sans-only) */
|
||||
--serif: 'Source Serif Pro', Georgia, serif;
|
||||
--serif-warm: 'Cormorant Garamond', Georgia, serif;
|
||||
--hand: 'Caveat', 'Brush Script MT', cursive;
|
||||
--sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--mono: 'JetBrains Mono', Menlo, monospace;
|
||||
|
||||
/* derived families — overridden by data-typography */
|
||||
--font-body: var(--sans);
|
||||
--font-label: var(--sans);
|
||||
|
||||
/* spacing scale — overridden by data-density */
|
||||
--msg-gap: 1.5rem;
|
||||
--max-col: 36rem;
|
||||
--body-size: 1rem;
|
||||
--label-size: 0.72rem;
|
||||
--bt-size: 1.05rem;
|
||||
|
||||
/* prompt baseline */
|
||||
--line-thickness: 1px;
|
||||
}
|
||||
|
||||
/* ===================== PALETTES ===================== */
|
||||
|
||||
body[data-palette="rose"] {
|
||||
--bg: #f5ebe0;
|
||||
--bg-soft: #f8e8db;
|
||||
--surface: #f0d9c7;
|
||||
--ink: #3d2820;
|
||||
--ink-muted: #8b6f60;
|
||||
--ink-faint: #b89a87;
|
||||
--coral: #e07856;
|
||||
--red: #c54f3d;
|
||||
--line: #e8d4c0;
|
||||
}
|
||||
|
||||
body[data-palette="morning"] {
|
||||
--bg: #faf3e3;
|
||||
--bg-soft: #f4ead0;
|
||||
--surface: #ecdfb8;
|
||||
--ink: #3a2f1a;
|
||||
--ink-muted: #7a6a4a;
|
||||
--ink-faint: #b6a684;
|
||||
--coral: #d99b4a;
|
||||
--red: #b5703a;
|
||||
--line: #e7d6a8;
|
||||
}
|
||||
|
||||
body[data-palette="evening"] {
|
||||
--bg: #ece1de;
|
||||
--bg-soft: #e4d2cd;
|
||||
--surface: #d8bdb6;
|
||||
--ink: #2e1b1d;
|
||||
--ink-muted: #7d5a59;
|
||||
--ink-faint: #b08c8a;
|
||||
--coral: #b04a55;
|
||||
--red: #84313e;
|
||||
--line: #d6bbb6;
|
||||
}
|
||||
|
||||
body[data-palette="sage"] {
|
||||
--bg: #ecede4;
|
||||
--bg-soft: #e1e3d4;
|
||||
--surface: #cdd1bd;
|
||||
--ink: #25291e;
|
||||
--ink-muted: #5d6451;
|
||||
--ink-faint: #97a085;
|
||||
--coral: #6f8861;
|
||||
--red: #58683f;
|
||||
--line: #cfd4be;
|
||||
}
|
||||
|
||||
body[data-palette="paper"] {
|
||||
--bg: #f7f4ed;
|
||||
--bg-soft: #efeada;
|
||||
--surface: #e1d9c2;
|
||||
--ink: #1d1c19;
|
||||
--ink-muted: #6b6759;
|
||||
--ink-faint: #a8a292;
|
||||
--coral: #8b5d2f;
|
||||
--red: #6a3f1b;
|
||||
--line: #d8cfb6;
|
||||
}
|
||||
|
||||
body[data-palette="ink"] {
|
||||
--bg: #1d1d1f;
|
||||
--bg-soft: #26262a;
|
||||
--surface: #2f2f34;
|
||||
--ink: #e8e6df;
|
||||
--ink-muted: #9d9a90;
|
||||
--ink-faint: #65635c;
|
||||
--coral: #d8a572;
|
||||
--red: #b87653;
|
||||
--line: #353539;
|
||||
}
|
||||
|
||||
/* ===================== TYPOGRAPHY SETS ===================== */
|
||||
|
||||
body[data-typography="sans"] {
|
||||
--font-body: var(--sans);
|
||||
--font-label: var(--sans);
|
||||
}
|
||||
body[data-typography="serif-warm"] {
|
||||
--font-body: var(--serif-warm);
|
||||
--font-label: var(--hand);
|
||||
--bt-size: 1.25rem;
|
||||
}
|
||||
body[data-typography="serif-formal"] {
|
||||
--font-body: var(--serif);
|
||||
--font-label: var(--sans);
|
||||
--bt-size: 1.15rem;
|
||||
}
|
||||
body[data-typography="mixed-modern"] {
|
||||
--font-body: var(--sans);
|
||||
--font-label: var(--hand);
|
||||
}
|
||||
body[data-typography="mono"] {
|
||||
--font-body: var(--mono);
|
||||
--font-label: var(--mono);
|
||||
--bt-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* ===================== DENSITY ===================== */
|
||||
body[data-density="airy"] { --msg-gap: 2.3rem; --max-col: 38rem; --body-size: 1.05rem; }
|
||||
body[data-density="normal"] { --msg-gap: 1.5rem; --max-col: 36rem; --body-size: 1rem; }
|
||||
body[data-density="dense"] { --msg-gap: 1rem; --max-col: 34rem; --body-size: 0.95rem; }
|
||||
|
||||
/* ===================== RESET ===================== */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-weight: 400;
|
||||
font-size: var(--body-size);
|
||||
line-height: 1.55;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ===================== LAYOUT ===================== */
|
||||
.page {
|
||||
max-width: var(--max-col);
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.5rem 0 1.5rem;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---------- top-nav strip (recalibrate · sign out · sigil) ---------- */
|
||||
.topnav {
|
||||
position: fixed;
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
z-index: 10;
|
||||
}
|
||||
.topnav__link {
|
||||
font-family: var(--sans);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.45;
|
||||
transition: opacity 400ms ease, color 400ms ease;
|
||||
}
|
||||
.topnav__link:hover,
|
||||
.topnav__link:focus-visible {
|
||||
opacity: 1;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
.topnav__link:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: progress;
|
||||
}
|
||||
.topnav__sep {
|
||||
color: var(--ink-faint);
|
||||
opacity: 0.35;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.sigil {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0.35;
|
||||
transition: opacity 600ms ease;
|
||||
user-select: none;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
.sigil:hover { opacity: 0.85; }
|
||||
.sigil img { width: 100%; height: 100%; display: block; }
|
||||
|
||||
/* ===================== CONVERSATION ===================== */
|
||||
.conversation {
|
||||
flex: 1 0 auto;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin: var(--msg-gap) 0;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
animation: appear 500ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.msg__label {
|
||||
font-family: var(--font-label);
|
||||
font-weight: 400;
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--label-size);
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.45rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Cursive label override */
|
||||
body[data-labels="cursive"] .msg__label {
|
||||
font-family: var(--hand);
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: none;
|
||||
}
|
||||
body[data-labels="none"] .msg__label { display: none; }
|
||||
body[data-labels="prefix"] .msg__label {
|
||||
display: inline;
|
||||
margin-right: 0.5em;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.msg__body {
|
||||
color: var(--ink);
|
||||
font-size: var(--bt-size);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.msg--user .msg__body {
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--body-size);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.msg--system .msg__body {
|
||||
color: var(--ink-faint);
|
||||
font-size: 0.74rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-family: var(--sans);
|
||||
font-weight: 300;
|
||||
}
|
||||
.msg--system .msg__label { display: none; }
|
||||
|
||||
.msg--calibration .msg__body {
|
||||
color: var(--ink);
|
||||
font-size: calc(var(--bt-size) * 1.02);
|
||||
font-style: italic;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.msg--calibration .msg__label { display: none; }
|
||||
.msg--calibration { margin: 1.5rem 0; }
|
||||
|
||||
/* repeat-label hide */
|
||||
.msg[data-hide-label="true"] .msg__label { display: none; }
|
||||
.msg[data-hide-label="true"] { margin-top: 0.8rem; }
|
||||
|
||||
/* ===================== THINKING ===================== */
|
||||
.thinking {
|
||||
color: var(--coral);
|
||||
font-size: 0.65rem;
|
||||
margin: 1.5rem 0;
|
||||
user-select: none;
|
||||
animation: pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
.thinking::before { content: "●"; }
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.30; }
|
||||
50% { opacity: 1.0; }
|
||||
}
|
||||
@keyframes appear {
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* typewriter cursor */
|
||||
.caret {
|
||||
display: inline-block;
|
||||
width: 0.5ch;
|
||||
margin-left: 0.05ch;
|
||||
color: var(--coral);
|
||||
animation: caret 800ms ease-out forwards;
|
||||
}
|
||||
.caret::after { content: "▍"; }
|
||||
@keyframes caret {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ===================== PROMPT ===================== */
|
||||
.prompt {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--bg);
|
||||
padding: 1.5rem 0 2.5rem 0;
|
||||
z-index: 5;
|
||||
}
|
||||
.prompt__line {
|
||||
border: none;
|
||||
border-top: var(--line-thickness) solid var(--line);
|
||||
margin: 0 0 0.85rem 0;
|
||||
}
|
||||
.prompt__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.prompt__input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 400;
|
||||
font-size: var(--body-size);
|
||||
color: var(--ink);
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.prompt__input::placeholder {
|
||||
color: var(--ink-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
.prompt__input:focus { caret-color: var(--coral); }
|
||||
|
||||
/* ===================== MIC ===================== */
|
||||
.prompt__mic {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--ink-faint);
|
||||
padding: 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: color 200ms ease, background 200ms ease, transform 200ms ease;
|
||||
}
|
||||
.prompt__mic:hover { color: var(--ink-muted); }
|
||||
.prompt__mic:active { transform: scale(0.95); }
|
||||
.prompt__mic.recording {
|
||||
color: var(--coral);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
animation: mic-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.prompt__mic.transcribing {
|
||||
color: var(--gold);
|
||||
animation: mic-spin 1.2s linear infinite;
|
||||
}
|
||||
.prompt__mic.empty {
|
||||
color: var(--red);
|
||||
animation: mic-empty 0.7s ease;
|
||||
}
|
||||
@keyframes mic-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.18); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(0, 0, 0, 0); }
|
||||
}
|
||||
@keyframes mic-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes mic-empty {
|
||||
0% { transform: translateX(0); }
|
||||
20% { transform: translateX(-2px); }
|
||||
40% { transform: translateX(2px); }
|
||||
60% { transform: translateX(-2px); }
|
||||
80% { transform: translateX(2px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ===================== CHOICE TILES ===================== */
|
||||
.choices {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
margin: 1rem 0 0.25rem 0;
|
||||
}
|
||||
.choice {
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.95rem;
|
||||
font-style: normal;
|
||||
padding: 0.5rem 0.95rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
transition: background 220ms ease, color 220ms ease,
|
||||
border-color 220ms ease, transform 220ms ease, opacity 220ms ease;
|
||||
}
|
||||
.choice:hover {
|
||||
background: var(--surface);
|
||||
border-color: var(--coral);
|
||||
}
|
||||
.choice:active { transform: scale(0.97); }
|
||||
.choice:disabled { cursor: default; opacity: 0.45; }
|
||||
.choice--selected {
|
||||
background: var(--coral);
|
||||
color: var(--bg);
|
||||
border-color: var(--coral);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.choice--other {
|
||||
font-style: italic;
|
||||
color: var(--ink-muted);
|
||||
background: transparent;
|
||||
}
|
||||
.choice__icon { font-size: 1.05rem; line-height: 1; display: inline-flex; }
|
||||
.choice__label { line-height: 1; }
|
||||
|
||||
/* ===================== DENIED PAGE ===================== */
|
||||
.denied {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.denied__title {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.7rem;
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
.denied__body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
color: var(--ink-muted);
|
||||
max-width: 28rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.denied__link {
|
||||
font-family: var(--sans);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--red);
|
||||
text-decoration: none;
|
||||
margin-top: 3rem;
|
||||
display: inline-block;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
.denied__link:hover { border-color: var(--red); }
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user