solace / index.html
ogNla's picture
feat: initial Solace AI therapy companion app
0242046
Raw
History Blame Contribute Delete
57.6 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Solace — therapy companion</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
/* ── Design tokens (exact from design file) ── */
:root {
--bg: #FAFAF7;
--bg-2: #F4F2EC;
--paper: #FFFFFF;
--ink: #1B1D1B;
--ink-2: #3A3D3A;
--muted: #8A8D88;
--muted-2: #B8BAB4;
--line: rgba(27,29,27,.08);
--line-2: rgba(27,29,27,.04);
--sage: #4A9B8F;
--sage-deep: #2F6F66;
--sage-soft: #D9E8E4;
--sage-tint: #EEF5F2;
--warn: #C8553D;
--warn-soft: #F4D9D2;
--shadow-sm: 0 1px 2px rgba(27,29,27,.04), 0 1px 1px rgba(27,29,27,.03);
--shadow-md: 0 8px 24px rgba(27,29,27,.06), 0 2px 6px rgba(27,29,27,.04);
--shadow-lg: 0 30px 60px rgba(27,29,27,.08), 0 10px 24px rgba(27,29,27,.06);
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 24px;
--radius-xl: 32px;
}
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
background: var(--bg);
color: var(--ink);
font-family: "Plus Jakarta Sans", system-ui, -apple-system, sans-serif;
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow: hidden;
}
.serif { font-family: "Instrument Serif", "Lora", Georgia, serif; }
.serif-i { font-family: "Instrument Serif", "Lora", Georgia, serif; font-style: italic; }
.mono { font-family: "JetBrains Mono", ui-monospace, monospace; }
/* ── Brand ── */
.brand { display: inline-flex; align-items: center; gap: 10px; color: var(--ink); }
.brand-mark {
width: 26px; height: 26px; border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #6FB8AD 0%, var(--sage) 55%, var(--sage-deep) 100%);
box-shadow: inset 0 -3px 6px rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.08);
flex-shrink: 0;
}
.brand-word { font-family: "Instrument Serif", serif; font-size: 22px; letter-spacing: -.01em; }
/* ══════════════════════════════════════════════
APP SHELL — full viewport session
══════════════════════════════════════════════ */
#app {
height: 100vh;
display: flex;
flex-direction: column;
}
.session {
display: grid;
grid-template-columns: 60% 40%;
flex: 1;
min-height: 0;
}
/* ── Left: Robot stage ── */
.stage {
position: relative;
background: radial-gradient(120% 80% at 50% 35%, #F1EEE5 0%, var(--bg-2) 55%, #E8E4D9 100%);
display: flex;
flex-direction: column;
overflow: hidden;
}
.stage-top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px 0;
z-index: 3;
position: relative;
}
.stage-top .pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(255,255,255,.75);
backdrop-filter: blur(8px);
border: 1px solid var(--line);
border-radius: 999px;
font-size: 12px;
color: var(--ink-2);
}
.pill .pulse-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--sage);
box-shadow: 0 0 0 0 rgba(74,155,143,.5);
animation: pulse-dot 2s ease-out infinite;
}
@keyframes pulse-dot {
0% { box-shadow: 0 0 0 0 rgba(74,155,143,.5); }
70% { box-shadow: 0 0 0 8px rgba(74,155,143,0); }
100% { box-shadow: 0 0 0 0 rgba(74,155,143,0); }
}
/* Sim container + rings overlay */
.sim-wrap {
flex: 1;
position: relative;
min-height: 0;
}
#sim-container {
position: absolute;
inset: 0;
z-index: 1;
}
.rings-overlay {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.ring {
position: absolute;
border-radius: 50%;
border: 1px solid rgba(74,155,143,.22);
animation: breathe 4.5s ease-in-out infinite;
}
.ring.r1 { width: 280px; height: 280px; animation-delay: 0s; }
.ring.r2 { width: 360px; height: 360px; animation-delay: .4s; border-color: rgba(74,155,143,.14); }
.ring.r3 { width: 460px; height: 460px; animation-delay: .8s; border-color: rgba(74,155,143,.08); }
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: .9; }
50% { transform: scale(1.06); opacity: .4; }
}
/* Stage bottom bar */
.stage-bottom {
position: relative;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
margin: 0 16px 16px;
background: rgba(255,255,255,.7);
backdrop-filter: blur(10px);
border: 1px solid var(--line);
border-radius: 14px;
}
#session-timer {
font-family: "JetBrains Mono", monospace;
font-size: 13px;
letter-spacing: .04em;
color: var(--ink-2);
display: flex;
align-items: center;
gap: 8px;
}
#session-timer .rec-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--warn);
animation: rec-blink 1.4s ease-in-out infinite;
}
@keyframes rec-blink { 0%,100%{opacity:1} 50%{opacity:.3} }
.waveform {
display: flex;
align-items: center;
gap: 3px;
height: 22px;
opacity: 0;
transition: opacity .3s;
}
.waveform.active { opacity: 1; }
.waveform span {
display: block;
width: 3px;
background: var(--sage);
border-radius: 2px;
animation: wave 1.1s ease-in-out infinite;
}
.waveform span:nth-child(1) { height: 30%; animation-delay: 0s; }
.waveform span:nth-child(2) { height: 65%; animation-delay: .08s; }
.waveform span:nth-child(3) { height: 90%; animation-delay: .16s; }
.waveform span:nth-child(4) { height: 55%; animation-delay: .24s; }
.waveform span:nth-child(5) { height: 80%; animation-delay: .32s; }
.waveform span:nth-child(6) { height: 40%; animation-delay: .40s; }
.waveform span:nth-child(7) { height: 70%; animation-delay: .48s; }
.waveform span:nth-child(8) { height: 50%; animation-delay: .56s; }
.waveform span:nth-child(9) { height: 85%; animation-delay: .64s; }
.waveform span:nth-child(10) { height: 35%; animation-delay: .72s; }
@keyframes wave {
0%, 100% { transform: scaleY(.6); }
50% { transform: scaleY(1.4); }
}
/* ── Right: Conversation panel ── */
.convo {
display: flex;
flex-direction: column;
padding: 24px 28px 20px;
border-left: 1px solid var(--line);
background: var(--paper);
overflow: hidden;
}
.convo-head {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 18px;
border-bottom: 1px solid var(--line-2);
flex-shrink: 0;
}
.convo-head .title {
font-family: "Instrument Serif", serif;
font-size: 20px;
letter-spacing: -.01em;
}
.convo-head .title em { font-style: italic; color: var(--sage-deep); }
.mood-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 10px 5px 8px;
background: var(--sage-tint);
border: 1px solid var(--sage-soft);
border-radius: 999px;
color: var(--sage-deep);
font-size: 12px;
font-weight: 500;
transition: all .5s ease;
}
.mood-chip .glyph { font-size: 13px; }
/* Robot speech card */
#robot-speech {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 20px 0 16px;
min-height: 0;
}
.speech-label {
font-size: 11px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--muted);
font-weight: 500;
margin-bottom: 12px;
}
.speech-quote {
font-family: "Instrument Serif", serif;
font-style: italic;
font-size: 26px;
line-height: 1.3;
color: var(--ink);
letter-spacing: -.005em;
text-wrap: pretty;
animation: fade-up .8s ease-out;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.speech-quote::before {
content: "\201C";
font-size: 48px;
line-height: 0;
vertical-align: -.4em;
margin-right: 3px;
color: var(--sage);
opacity: .5;
}
/* Transcript */
#transcript {
border-top: 1px solid var(--line-2);
padding-top: 12px;
max-height: 130px;
overflow-y: auto;
scrollbar-width: none;
mask-image: linear-gradient(to bottom, transparent 0, #000 20px, #000 calc(100% - 20px), transparent 100%);
flex-shrink: 0;
}
#transcript::-webkit-scrollbar { display: none; }
.t-row {
display: grid;
grid-template-columns: 58px 1fr;
gap: 10px;
padding: 5px 0;
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
.t-row .who {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--muted-2);
padding-top: 1px;
}
.t-row .who.solace { color: var(--sage); }
.t-row p { margin: 0; color: var(--ink-2); }
/* Conversation footer */
.convo-foot {
padding-top: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
#state-label {
font-size: 12px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--muted);
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
transition: color .3s;
}
#state-label .ind {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--sage);
animation: pulse-soft 1.6s ease-in-out infinite;
}
#state-label.listening { color: var(--warn); }
#state-label.listening .ind { background: var(--warn); }
@keyframes pulse-soft {
0%,100% { opacity: .4; }
50% { opacity: 1; }
}
/* ── Mic button ── */
.mic {
--c: var(--sage);
position: relative;
width: 80px; height: 80px;
border-radius: 50%;
background: var(--c);
border: none;
cursor: pointer;
color: white;
display: flex; align-items: center; justify-content: center;
box-shadow:
0 0 0 0 color-mix(in oklab, var(--c) 40%, transparent),
0 12px 28px color-mix(in oklab, var(--c) 35%, transparent),
inset 0 -3px 8px rgba(0,0,0,.15);
transition: transform .2s ease, background .3s ease, box-shadow .3s ease;
animation: mic-pulse 2.6s ease-out infinite;
}
.mic:hover:not(:disabled) { transform: translateY(-1px) scale(1.03); }
.mic:active:not(:disabled) { transform: scale(.97); }
.mic svg { width: 26px; height: 26px; }
@keyframes mic-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--c) 45%, transparent), 0 12px 28px color-mix(in oklab, var(--c) 35%, transparent), inset 0 -3px 8px rgba(0,0,0,.15); }
70% { box-shadow: 0 0 0 18px color-mix(in oklab, var(--c) 0%, transparent), 0 12px 28px color-mix(in oklab, var(--c) 35%, transparent), inset 0 -3px 8px rgba(0,0,0,.15); }
100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--c) 0%, transparent), 0 12px 28px color-mix(in oklab, var(--c) 35%, transparent), inset 0 -3px 8px rgba(0,0,0,.15); }
}
.mic[data-state="listening"] { --c: #C8553D; animation: mic-pulse 1.3s ease-out infinite; }
.mic[data-state="processing"] {
--c: #B8BAB4; animation: none; cursor: wait;
box-shadow: 0 8px 20px rgba(0,0,0,.08), inset 0 -3px 8px rgba(0,0,0,.1);
}
.mic[data-state="processing"] .spinner {
width: 28px; height: 28px;
border: 2.5px solid rgba(255,255,255,.3);
border-top-color: white;
border-radius: 50%;
animation: spin .9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.mic[data-state="speaking"] {
--c: var(--sage); animation: none; cursor: not-allowed; opacity: .9;
}
.mic[data-state="speaking"] .speak-wave {
display: flex; align-items: center; gap: 3px; height: 26px;
}
.mic[data-state="speaking"] .speak-wave span {
width: 3px; background: white; border-radius: 2px;
animation: wave 1s ease-in-out infinite;
}
.mic[data-state="speaking"] .speak-wave span:nth-child(1) { height: 40%; animation-delay: 0s; }
.mic[data-state="speaking"] .speak-wave span:nth-child(2) { height: 80%; animation-delay: .1s; }
.mic[data-state="speaking"] .speak-wave span:nth-child(3) { height: 100%; animation-delay: .2s; }
.mic[data-state="speaking"] .speak-wave span:nth-child(4) { height: 65%; animation-delay: .3s; }
.mic[data-state="speaking"] .speak-wave span:nth-child(5) { height: 45%; animation-delay: .4s; }
.disclaimer {
font-size: 10px;
color: var(--muted-2);
letter-spacing: .02em;
text-align: center;
max-width: 220px;
line-height: 1.5;
}
/* ══════════════════════════════════════════════
SETUP OVERLAY
══════════════════════════════════════════════ */
#setup-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(60% 50% at 20% 20%, rgba(74,155,143,.06) 0%, transparent 60%),
radial-gradient(50% 40% at 90% 80%, rgba(74,155,143,.05) 0%, transparent 60%),
var(--bg);
}
#setup-overlay.hidden { display: none; }
.setup-card {
width: 92%;
max-width: 460px;
background: var(--paper);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
border: 1px solid var(--line);
overflow: hidden;
position: relative;
}
.setup-card::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, transparent 0%, rgba(74,155,143,0) 5%, var(--sage) 50%, rgba(74,155,143,0) 95%, transparent 100%);
}
.setup-inner { padding: 40px 36px 36px; }
.setup-brand { display: flex; justify-content: center; margin-bottom: 22px; }
.setup-h {
font-family: "Instrument Serif", serif;
font-size: 36px;
line-height: 1.05;
text-align: center;
margin: 0 0 8px;
letter-spacing: -.01em;
}
.setup-h em { font-style: italic; color: var(--sage-deep); }
.setup-sub {
text-align: center;
color: var(--muted);
font-size: 14px;
margin: 0 0 28px;
max-width: 300px;
margin-inline: auto;
text-wrap: pretty;
line-height: 1.6;
}
/* Key grid — two columns */
.key-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
.key-grid .field { margin-bottom: 0; }
.field { margin-bottom: 14px; }
.field-label {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--ink-2);
font-weight: 500;
margin-bottom: 6px;
letter-spacing: .01em;
}
.field-label .hint { color: var(--muted); font-weight: 400; font-size: 11px; }
.field-input { position: relative; }
.field-input input, .field-input select {
width: 100%;
padding: 11px 40px 11px 13px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-family: "JetBrains Mono", monospace;
font-size: 12.5px;
color: var(--ink);
outline: none;
transition: border-color .15s ease, box-shadow .15s ease;
-webkit-appearance: none;
}
.field-input select { padding-right: 28px; cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238A8D88' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 10px center; }
.field-input input::placeholder { color: var(--muted-2); font-family: "Plus Jakarta Sans", sans-serif; font-size: 12px; }
.field-input input:focus, .field-input select:focus {
border-color: var(--sage);
background: var(--paper);
box-shadow: 0 0 0 3px rgba(74,155,143,.12);
}
.field-input select option { background: var(--paper); color: var(--ink); }
.eye {
position: absolute;
right: 10px; top: 50%;
transform: translateY(-50%);
background: none; border: none;
padding: 5px; color: var(--muted);
cursor: pointer; display: flex; align-items: center;
border-radius: 6px;
}
.eye:hover { color: var(--ink-2); background: var(--bg-2); }
.eye svg { width: 15px; height: 15px; }
/* Segmented robot toggle */
.segmented {
display: flex;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 4px; gap: 4px;
}
.segmented button {
flex: 1; background: transparent; border: none;
padding: 9px 12px; border-radius: 7px;
font-family: inherit; font-size: 13px;
color: var(--muted); cursor: pointer; font-weight: 500;
display: flex; align-items: center; justify-content: center; gap: 7px;
transition: color .15s, background .2s;
}
.segmented button.active {
background: var(--paper);
color: var(--ink);
box-shadow: var(--shadow-sm);
}
.segmented button .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted-2); }
.segmented button.active .dot { background: var(--sage); }
/* Advanced endpoints */
details.advanced {
margin-top: 16px;
border-top: 1px solid var(--line-2);
padding-top: 14px;
}
details.advanced summary {
list-style: none; cursor: pointer;
display: flex; align-items: center; justify-content: space-between;
font-size: 12px; color: var(--muted); font-weight: 500; letter-spacing: .02em;
user-select: none;
}
details.advanced summary::-webkit-details-marker { display: none; }
details.advanced summary::after {
content: "+"; font-family: "Instrument Serif", serif;
font-size: 20px; line-height: 1; color: var(--muted); transition: transform .2s;
}
details.advanced[open] summary::after { content: "−"; }
details.advanced .adv-body { padding-top: 12px; display: flex; flex-direction: column; gap: 10px; }
.cta {
width: 100%; margin-top: 22px; padding: 14px 18px;
background: var(--ink); color: white; border: none;
border-radius: var(--radius-sm); font-family: inherit;
font-size: 15px; font-weight: 500; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 10px;
transition: background .2s, transform .15s;
letter-spacing: .005em;
}
.cta:hover { background: #000; transform: translateY(-1px); }
.cta:active { transform: none; }
.cta:disabled { opacity: .5; cursor: wait; transform: none; }
.cta .arrow { font-family: "Instrument Serif", serif; font-style: italic; font-size: 18px; line-height: 1; }
.setup-foot { text-align: center; margin-top: 14px; font-size: 11px; color: var(--muted-2); letter-spacing: .02em; }
#cfg-err {
font-size: 12px; color: var(--warn);
margin-top: 10px; display: none; line-height: 1.5;
padding: 10px 12px; background: var(--warn-soft);
border-radius: 8px;
}
/* Robot picker modal */
#picker-modal {
position: fixed; inset: 0; z-index: 200;
display: none; align-items: center; justify-content: center;
background: rgba(27,29,27,.4); backdrop-filter: blur(8px);
}
#picker-modal.open { display: flex; }
.picker-card {
background: var(--paper); border: 1px solid var(--line);
border-radius: var(--radius-lg); padding: 28px 24px;
width: 340px; box-shadow: var(--shadow-lg);
}
.picker-card h3 { font-family: "Instrument Serif", serif; font-size: 22px; margin: 0 0 16px; }
.picker-card h3 em { font-style: italic; color: var(--sage-deep); }
.robot-option {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px; border-radius: var(--radius-md);
border: 1px solid var(--line); cursor: pointer; margin-bottom: 8px;
transition: all .2s; background: var(--bg);
}
.robot-option:hover { border-color: var(--sage); background: var(--sage-tint); }
.robot-option-name { font-size: 13px; font-weight: 500; }
.robot-option-meta { font-family: "JetBrains Mono", monospace; font-size: 10px; color: var(--muted); }
/* Responsive */
@media (max-width: 740px) {
.session { grid-template-columns: 1fr; grid-template-rows: 52vw 1fr; }
.convo { border-left: none; border-top: 1px solid var(--line); padding: 16px 18px 14px; }
.speech-quote { font-size: 20px; }
.setup-card { width: 96%; }
.key-grid { grid-template-columns: 1fr; gap: 0; }
}
#remoteVideo { display: none; }
</style>
</head>
<body>
<!-- ══════════════════════════════════════════════
SETUP OVERLAY
══════════════════════════════════════════════ -->
<div id="setup-overlay">
<div class="setup-card">
<div class="setup-inner">
<div class="setup-brand">
<span class="brand">
<span class="brand-mark"></span>
<span class="brand-word">Solace</span>
</span>
</div>
<h2 class="setup-h">Start your <em>session</em></h2>
<p class="setup-sub">Speak freely. Solace listens without judgment.</p>
<!-- Two keys side by side -->
<div class="key-grid">
<div class="field">
<div class="field-label"><span>Chat key</span><span class="hint">LLM</span></div>
<div class="field-input">
<input id="cfg-chat-key" type="password" placeholder="sk-… or app-sk-…" autocomplete="off" />
<button class="eye" type="button" data-for="cfg-chat-key" aria-label="Show key">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
<div class="field">
<div class="field-label"><span>Whisper key</span><span class="hint">STT</span></div>
<div class="field-input">
<input id="cfg-stt-key" type="password" placeholder="sk-… or app-sk-…" autocomplete="off" />
<button class="eye" type="button" data-for="cfg-stt-key" aria-label="Show key">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
</div>
<!-- Robot mode — segmented toggle -->
<div class="field">
<div class="field-label"><span>Mode</span></div>
<div class="segmented" id="mode-toggle">
<button type="button" class="active" data-mode="sim"><span class="dot"></span>Simulator</button>
<button type="button" data-mode="live"><span class="dot"></span>Live Robot</button>
</div>
</div>
<!-- Advanced endpoints -->
<details class="advanced">
<summary>Advanced endpoints</summary>
<div class="adv-body">
<div class="field" style="margin:0">
<div class="field-label"><span>Chat model</span></div>
<div class="field-input">
<select id="cfg-model">
<option value="zai-org/GLM-5-FP8">GLM-5 FP8 (recommended)</option>
<option value="deepseek/deepseek-chat-v3-0324">DeepSeek Chat v3</option>
<option value="qwen3.6-plus">Qwen 3.6 Plus</option>
</select>
</div>
</div>
<div class="field" style="margin:0">
<div class="field-label"><span>Chat endpoint</span></div>
<div class="field-input">
<select id="cfg-chat-url">
<option value="https://router-api.0g.ai/v1/chat/completions">Router API — router-api.0g.ai (sk-… key)</option>
<option value="https://compute-network-1.integratenetwork.work/v1/proxy/chat/completions">compute-network-1 (app-sk-… key)</option>
<option value="https://compute-network-2.integratenetwork.work/v1/proxy/chat/completions">compute-network-2</option>
<option value="https://compute-network-3.integratenetwork.work/v1/proxy/chat/completions">compute-network-3</option>
</select>
</div>
</div>
<div class="field" style="margin:0">
<div class="field-label"><span>Whisper endpoint</span></div>
<div class="field-input">
<select id="cfg-stt-url">
<option value="https://router-api.0g.ai/v1/audio/transcriptions">Router API — router-api.0g.ai (sk-… key)</option>
<option value="https://compute-network-16.integratenetwork.work/v1/proxy/audio/transcriptions">compute-network-16 (app-sk-… key)</option>
</select>
</div>
</div>
</div>
</details>
<div id="cfg-err"></div>
<button class="cta" id="setup-begin" type="button" onclick="startApp()">
Begin <span class="arrow"></span>
</button>
<div class="setup-foot">Keys stored locally on this device only.</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════
ROBOT PICKER MODAL
══════════════════════════════════════════════ -->
<div id="picker-modal">
<div class="picker-card">
<h3>Choose a <em>robot</em></h3>
<div id="picker-list"></div>
</div>
</div>
<!-- ══════════════════════════════════════════════
MAIN APP
══════════════════════════════════════════════ -->
<div id="app" style="display:none">
<div class="session">
<!-- Left: robot stage -->
<section class="stage">
<div class="stage-top">
<span class="brand">
<span class="brand-mark"></span>
<span class="brand-word">Solace</span>
</span>
<span class="pill" id="connection-pill">
<span class="pulse-dot"></span>
<span id="pill-text">Simulator</span>
</span>
</div>
<div class="sim-wrap">
<div id="sim-container"></div>
<div class="rings-overlay">
<div class="ring r1"></div>
<div class="ring r2"></div>
<div class="ring r3"></div>
</div>
</div>
<div class="stage-bottom">
<div id="session-timer">
<span class="rec-dot"></span>
<span id="timer-text">00:00:00</span>
</div>
<div class="waveform" id="waveform" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
</div>
<div class="mono" style="font-size:11px;color:var(--muted);letter-spacing:.04em" id="stage-meta">SIM</div>
</div>
</section>
<!-- Right: conversation -->
<section class="convo">
<header class="convo-head">
<div class="title">Session with <em>Solace</em></div>
<div class="mood-chip" id="mood-chip">
<span class="glyph" id="mood-emoji">🌿</span>
<span id="mood-label">Ready</span>
</div>
</header>
<div id="robot-speech">
<div class="speech-label" id="speech-label">Solace</div>
<div class="speech-quote" id="speech-text">
Take your time. There's no rush here — just speak when you're ready.
</div>
</div>
<div id="transcript" aria-label="Session transcript"></div>
<footer class="convo-foot">
<div id="state-label">
<span class="ind"></span>
<span id="state-text">Ready to listen</span>
</div>
<button id="talk-btn" class="mic" data-state="idle" onclick="toggleRecording()" aria-label="Talk to Solace">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="3" width="6" height="12" rx="3" fill="currentColor" stroke="none"/>
<path d="M5 11a7 7 0 0 0 14 0"/>
<line x1="12" y1="18" x2="12" y2="22"/>
</svg>
</button>
<div class="disclaimer">Not a substitute for professional care. If you're in crisis, please reach a real person.</div>
</footer>
</section>
</div>
</div>
<video id="remoteVideo" autoplay playsinline></video>
<!-- ── 3D Sim module ── -->
<script type="module">
import { ReachySim } from './sim.js';
window.ReachySim = ReachySim;
</script>
<!-- ── Reachy Mini SDK (v1.7.3 — has autoConnect + correct signaling server) ── -->
<script type="module">
import { ReachyMini } from 'https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@v1.7.3/js/reachy-mini.js';
window.ReachyMini = ReachyMini;
</script>
<script>
// ════════════════════════════════════════════════════════════════════════════
// SOLACE — AI Therapy Companion
//
// 0G Integration:
// ─────────────────────────────────────────────────────────────────────────
// Uses two 0G Compute endpoints (OpenAI-compatible API format):
//
// CHAT → CFG.chatUrl with CFG.chatKey (Bearer app-sk-… or sk-…)
// Used for: therapy conversation + behavior/mood analysis
// Model: GLM-5 FP8 / DeepSeek / Qwen (your choice)
//
// WHISPER → CFG.sttUrl with CFG.sttKey (separate key!)
// Used for: speech-to-text transcription
// Model: openai/whisper-large-v3
//
// Router API (sk-… key): one key for both, endpoints on router-api.0g.ai
// Direct API (app-sk-… key): separate key per provider, compute-network-N
// ════════════════════════════════════════════════════════════════════════════
// ─── State ───────────────────────────────────────────────────────────────
let CFG = {};
let robot = null;
let isLive = false;
let appState = 'idle'; // idle | listening | processing | speaking
let conversationHistory = [];
let sessionStartTime = null;
let timerInterval = null;
let mediaRecorder = null;
let audioChunks = [];
let recAnalyser = null;
let recAnimFrame = null;
// ─── Therapy system prompt ────────────────────────────────────────────────
//
// This is the core of what makes Solace feel like a therapist vs a chatbot.
// Key principles baked in:
// - Validate before advising (never jump to solutions)
// - Ask only ONE open-ended question per response
// - Keep it brief (robot speech must be short)
// - Structured JSON output so we can drive robot behavior + mood UI
// - Safety guardrails for crisis situations
//
const SYSTEM_PROMPT = `You are Solace — a warm, emotionally intelligent robot therapy companion.
Your role is to create a safe, non-judgmental space for reflection. You practice active listening, compassionate validation, and gentle Socratic questioning. You help users explore their thoughts — you never diagnose, prescribe, or replace a licensed therapist.
Core rules:
- ALWAYS validate what the person said before anything else
- Ask exactly ONE open-ended, gentle question per response
- Keep replies under 50 words — you are a robot speaking aloud
- Never give unsolicited advice; only explore together
- Never use clinical jargon, "absolutely", or bullet points
- If the user expresses suicidal ideation or self-harm: respond with deep care AND include: "Please reach out — you deserve real support. Crisis line: 988 (US) or 116 123 (UK)."
Respond ONLY with valid JSON — no markdown, no code fences, no extra text:
{
"reply": "Your 1-3 sentence warm response. Validate first. End with one gentle question.",
"behavior": "one of: attentive | nod | comfort | encourage | thinking | breathe",
"mood": "one of: calm | heavy | anxious | hopeful | tender",
"moodEmoji": "one emoji representing the emotional tone"
}
Behavior meanings:
- attentive: listening closely, curious — default neutral posture
- nod: they felt heard, validating agreement
- comfort: pain or sadness detected, soothing presence
- encourage: progress, insight, or strength noticed
- thinking: reflecting together, pondering
- breathe: grounding moment, breathing together`;
// ─── Mood chip colors ─────────────────────────────────────────────────────
const MOODS = {
calm: { bg: 'var(--sage-tint)', border: 'var(--sage-soft)', color: 'var(--sage-deep)' },
heavy: { bg: '#EEF2F8', border: '#C8D5E8', color: '#2D4A7A' },
anxious: { bg: '#FEF3EC', border: '#F5D5B8', color: '#8B4513' },
hopeful: { bg: '#F0FBF0', border: '#B8E8B8', color: '#2D6B2D' },
tender: { bg: '#FEF0F5', border: '#F5C8D8', color: '#7A2D4A' },
};
// ════════════════════════════════════════════════════════════════════════════
// SETUP
// ════════════════════════════════════════════════════════════════════════════
// Restore saved settings
(function () {
try {
const s = JSON.parse(sessionStorage.getItem('solace-cfg') || '{}');
if (s.chatKey) document.getElementById('cfg-chat-key').value = s.chatKey;
if (s.sttKey) document.getElementById('cfg-stt-key').value = s.sttKey;
if (s.model) setSelectValue('cfg-model', s.model);
if (s.chatUrl) setSelectValue('cfg-chat-url', s.chatUrl);
if (s.sttUrl) setSelectValue('cfg-stt-url', s.sttUrl);
if (s.robotMode) {
document.querySelectorAll('#mode-toggle button').forEach(b => {
b.classList.toggle('active', b.dataset.mode === s.robotMode);
});
}
} catch (_) {}
})();
function setSelectValue(id, val) {
const sel = document.getElementById(id);
for (const opt of sel.options) if (opt.value === val) { opt.selected = true; break; }
}
// Segmented mode toggle
document.querySelectorAll('#mode-toggle button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#mode-toggle button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Eye toggles (show/hide key fields)
document.querySelectorAll('.eye').forEach(eye => {
eye.addEventListener('click', () => {
const input = document.getElementById(eye.dataset.for);
if (input) input.type = input.type === 'password' ? 'text' : 'password';
});
});
window.startApp = async function () {
const chatKey = document.getElementById('cfg-chat-key').value.trim();
const sttKey = document.getElementById('cfg-stt-key').value.trim();
if (!chatKey) return showCfgErr('Chat key is required.');
if (!sttKey) return showCfgErr('Whisper key is required.');
const activeMode = document.querySelector('#mode-toggle button.active');
CFG = {
chatKey,
sttKey,
chatUrl: document.getElementById('cfg-chat-url').value,
sttUrl: document.getElementById('cfg-stt-url').value,
model: document.getElementById('cfg-model').value,
robotMode: activeMode?.dataset.mode || 'sim',
};
// Verify chat key before starting
const btn = document.getElementById('setup-begin');
btn.disabled = true;
btn.innerHTML = 'Connecting…';
try {
const r = await fetch(CFG.chatUrl, {
method: 'POST',
headers: { 'Authorization': `Bearer ${CFG.chatKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: CFG.model, messages: [{ role: 'user', content: 'hi' }], max_tokens: 3 }),
});
if (!r.ok) throw new Error(`${r.status}${(await r.text()).slice(0, 120)}`);
} catch (e) {
btn.disabled = false;
btn.innerHTML = 'Begin <span class="arrow">→</span>';
return showCfgErr('Key check failed: ' + e.message + '\n\nCheck your key and endpoint match the same provider.');
}
sessionStorage.setItem('solace-cfg', JSON.stringify(CFG));
// Init robot
try {
await initRobot();
} catch (e) {
btn.disabled = false;
btn.innerHTML = 'Begin <span class="arrow">→</span>';
return showCfgErr('Robot connection failed: ' + e.message);
}
// Show app
document.getElementById('setup-overlay').classList.add('hidden');
document.getElementById('app').style.display = 'flex';
document.getElementById('app').style.flexDirection = 'column';
conversationHistory = [{ role: 'system', content: SYSTEM_PROMPT }];
startSessionTimer();
setState('idle');
};
function showCfgErr(msg) {
const el = document.getElementById('cfg-err');
el.textContent = msg;
el.style.display = 'block';
}
// ════════════════════════════════════════════════════════════════════════════
// ROBOT INIT
// ════════════════════════════════════════════════════════════════════════════
async function initRobot() {
if (CFG.robotMode === 'live') {
isLive = true;
// SDK v1.7.3 — autoConnect handles: auth + connect + robot pick + startSession + ensureAwake
robot = new window.ReachyMini({ appName: 'solace' });
// Local dev: pass ?hf=<token> in URL to skip OAuth
const hfToken = new URLSearchParams(location.search).get('hf')
|| new URLSearchParams((location.hash || '').replace(/^#/, '')).get('hf_token');
await robot.autoConnect({
token: hfToken || undefined,
autoPickIfSingle: true,
wakeOnConnect: true,
pickRobot: async (robots) => showRobotPicker(robots),
});
robot.attachVideo(document.getElementById('remoteVideo'));
document.getElementById('pill-text').textContent = 'Reachy connected';
document.getElementById('stage-meta').textContent = 'LIVE';
} else {
isLive = false;
robot = new window.ReachySim(document.getElementById('sim-container'));
await new Promise((res, rej) => {
if (robot.ready) return res();
robot.addEventListener('ready', res, { once: true });
robot.addEventListener('error', (e) => rej(new Error('Sim load failed')), { once: true });
});
await robot.wakeUp();
document.getElementById('pill-text').textContent = 'Simulator';
document.getElementById('stage-meta').textContent = 'SIM';
}
}
function showRobotPicker(robots) {
return new Promise((resolve, reject) => {
if (!robots.length) { reject(new Error('No robots found.')); return; }
const modal = document.getElementById('picker-modal');
const list = document.getElementById('picker-list');
list.innerHTML = '';
robots.forEach(r => {
const d = document.createElement('div');
d.className = 'robot-option';
d.innerHTML = `<span style="font-size:20px">🤖</span>
<div><div class="robot-option-name">${r.name || r.id}</div>
<div class="robot-option-meta">${r.id.slice(0, 20)}…</div></div>`;
d.onclick = () => { modal.classList.remove('open'); resolve(r.id); };
list.appendChild(d);
});
modal.classList.add('open');
});
}
// ════════════════════════════════════════════════════════════════════════════
// 0G COMPUTE — THERAPY CONVERSATION (Chat Completions)
// ════════════════════════════════════════════════════════════════════════════
async function askSolace(userText) {
conversationHistory.push({ role: 'user', content: userText });
// 0G Chat API — fully OpenAI-compatible
// Authorization: Bearer <your key>
// Body: standard chat completions JSON
const res = await fetch(CFG.chatUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CFG.chatKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: CFG.model,
messages: conversationHistory.slice(-16), // last 8 turns + system
max_tokens: 220,
temperature: 0.8,
}),
});
if (!res.ok) {
const t = await res.text().catch(() => '');
throw new Error(`Chat ${res.status}: ${t.slice(0, 200)}`);
}
const data = await res.json();
const raw = (data.choices?.[0]?.message?.content || '').trim();
conversationHistory.push({ role: 'assistant', content: raw });
return parseTherapyResponse(raw);
}
// Robust JSON parser — handles code fences and extra prose from LLMs
function parseTherapyResponse(raw) {
let text = raw.trim();
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fence) text = fence[1].trim();
const s = text.indexOf('{'), e = text.lastIndexOf('}');
if (s !== -1 && e > s) text = text.slice(s, e + 1);
try {
const p = JSON.parse(text);
const validBehaviors = ['attentive','nod','comfort','encourage','thinking','breathe'];
const validMoods = ['calm','heavy','anxious','hopeful','tender'];
return {
reply: p.reply || 'I hear you.',
behavior: validBehaviors.includes(p.behavior) ? p.behavior : 'attentive',
mood: validMoods.includes(p.mood) ? p.mood : 'calm',
moodEmoji: p.moodEmoji || '🌿',
};
} catch (_) {
return {
reply: (raw.match(/"reply"\s*:\s*"([^"]+)"/) || [])[1] || 'I hear you.',
behavior: 'attentive',
mood: 'calm',
moodEmoji: '🌿',
};
}
}
// ════════════════════════════════════════════════════════════════════════════
// 0G COMPUTE — SPEECH-TO-TEXT (Whisper)
// ════════════════════════════════════════════════════════════════════════════
async function transcribe(webmBlob) {
const wav = await webmToWav(webmBlob);
// 0G Whisper API — same as OpenAI's audio/transcriptions endpoint
// Uses multipart/form-data — do NOT set Content-Type manually
const form = new FormData();
form.append('file', wav, 'audio.wav');
form.append('model', 'openai/whisper-large-v3');
form.append('response_format', 'json');
const res = await fetch(CFG.sttUrl, {
method: 'POST',
headers: { 'Authorization': `Bearer ${CFG.sttKey}` },
body: form,
});
if (!res.ok) {
const t = await res.text().catch(() => '');
throw new Error(`Whisper ${res.status}: ${t.slice(0, 200)}`);
}
return ((await res.json()).text || '').trim();
}
async function webmToWav(blob) {
const buf = await blob.arrayBuffer();
const actx = new (window.AudioContext || window.webkitAudioContext)();
const decoded = await actx.decodeAudioData(buf);
try { actx.close(); } catch (_) {}
const ch0 = decoded.getChannelData(0);
const sr = decoded.sampleRate;
const len = ch0.length;
const out = new ArrayBuffer(44 + len * 2);
const v = new DataView(out);
const ws = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
ws(0,'RIFF'); v.setUint32(4, 36+len*2, true); ws(8,'WAVE'); ws(12,'fmt ');
v.setUint32(16,16,true); v.setUint16(20,1,true); v.setUint16(22,1,true);
v.setUint32(24,sr,true); v.setUint32(28,sr*2,true); v.setUint16(32,2,true); v.setUint16(34,16,true);
ws(36,'data'); v.setUint32(40,len*2,true);
let off = 44;
for (let i = 0; i < len; i++) {
const s = Math.max(-1, Math.min(1, ch0[i]));
v.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
off += 2;
}
return new Blob([out], { type: 'audio/wav' });
}
// ════════════════════════════════════════════════════════════════════════════
// RECORDING
// ════════════════════════════════════════════════════════════════════════════
window.toggleRecording = function () {
if (appState === 'idle') startRecording();
else if (appState === 'listening') stopRecording();
};
async function startRecording() {
if (appState !== 'idle') return;
setState('listening');
try {
let stream;
if (isLive) {
// Use the robot's WebRTC audio track — NOT getUserMedia (that's the laptop mic)
const vid = document.getElementById('remoteVideo');
let tracks = [];
for (let i = 0; i < 10; i++) {
tracks = vid?.srcObject?.getAudioTracks?.() || [];
if (tracks.length) break;
await sleep(150);
}
stream = tracks.length ? new MediaStream(tracks) : await navigator.mediaDevices.getUserMedia({ audio: true });
} else {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
}
// Waveform visualization
const actx = new (window.AudioContext || window.webkitAudioContext)();
const source = actx.createMediaStreamSource(stream);
recAnalyser = actx.createAnalyser();
recAnalyser.fftSize = 64;
source.connect(recAnalyser);
animateWaveform();
document.getElementById('waveform').classList.add('active');
mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm',
});
audioChunks = [];
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.onstop = async () => {
cancelAnimationFrame(recAnimFrame);
try { actx.close(); } catch (_) {}
document.getElementById('waveform').classList.remove('active');
stream.getTracks().forEach(t => t.stop());
if (!audioChunks.length || (Date.now() - recStart) < 500) {
addTranscriptRow('system', 'Too short — speak for at least a second.');
setState('idle');
return;
}
await processSpeech(new Blob(audioChunks, { type: mediaRecorder.mimeType }));
};
const recStart = Date.now();
mediaRecorder.start(250);
} catch (e) {
addTranscriptRow('system', 'Mic error: ' + e.message);
setState('idle');
}
}
function stopRecording() {
if (mediaRecorder?.state !== 'inactive') mediaRecorder?.stop();
}
function animateWaveform() {
const bars = document.querySelectorAll('#waveform span');
const data = new Uint8Array(recAnalyser.frequencyBinCount);
function draw() {
recAnimFrame = requestAnimationFrame(draw);
recAnalyser.getByteFrequencyData(data);
bars.forEach((b, i) => {
const v = data[Math.floor(i * data.length / bars.length)] / 255;
b.style.height = Math.max(15, v * 90) + '%';
});
}
draw();
}
// ════════════════════════════════════════════════════════════════════════════
// MAIN FLOW: Speech → Transcribe → Respond → React
// ════════════════════════════════════════════════════════════════════════════
async function processSpeech(audioBlob) {
// Step 1: Transcribe
setState('processing');
showSpeechLabel('Transcribing…');
let transcript = '';
try {
transcript = await transcribe(audioBlob);
} catch (e) {
addTranscriptRow('system', 'Transcription error: ' + e.message);
setState('idle');
return;
}
if (!transcript) {
addTranscriptRow('system', 'No speech detected — try speaking closer.');
setState('idle');
return;
}
addTranscriptRow('user', transcript);
// Step 2: Ask Solace
showSpeechLabel('Reflecting…');
doBehavior('thinking'); // thinking posture while processing
let response;
try {
response = await askSolace(transcript);
} catch (e) {
addTranscriptRow('system', 'Error: ' + e.message);
setState('idle');
return;
}
// Step 3: React — update UI + robot movement + speech
await react(response);
}
async function react({ reply, behavior, mood, moodEmoji }) {
setState('speaking');
updateMoodChip(mood, moodEmoji);
updateSpeechCard(reply);
addTranscriptRow('solace', reply);
await Promise.all([
doBehavior(behavior),
speakWithWiggle(reply),
]);
setState('idle');
doBehavior('attentive'); // return to listening posture
}
// ════════════════════════════════════════════════════════════════════════════
// ROBOT BEHAVIORS — Therapy postures
// ════════════════════════════════════════════════════════════════════════════
async function doBehavior(type) {
if (!robot) return;
switch (type) {
case 'attentive':
// Slight forward lean — engaged listening posture
robot.setHeadRpyDeg?.(4, -5, 0);
robot.setAntennasDeg?.(12, -12);
break;
case 'nod':
// Slow, gentle nod — they feel heard
robot.setHeadRpyDeg?.(0, -14, 0);
await sleep(450);
robot.setHeadRpyDeg?.(0, 8, 0);
await sleep(350);
robot.setHeadRpyDeg?.(0, -8, 0);
await sleep(350);
robot.setHeadRpyDeg?.(0, 0, 0);
robot.setAntennasDeg?.(8, -8);
break;
case 'comfort':
// Gentle downward tilt — soothing, empathetic presence
robot.setAntennasDeg?.(-22, -22);
await sleep(300);
robot.setHeadRpyDeg?.(0, 18, 0);
await sleep(800);
robot.setHeadRpyDeg?.(5, 14, 0); // slight lean
break;
case 'encourage':
// Head lifts — noticing strength, hope
robot.setHeadRpyDeg?.(0, -16, 0);
robot.setAntennasDeg?.(22, 22);
await sleep(500);
robot.setHeadRpyDeg?.(0, -10, 0);
robot.setAntennasDeg?.(14, 14);
break;
case 'thinking':
// Head tilts to one side — considering together
robot.setHeadRpyDeg?.(18, 0, 12);
robot.setAntennasDeg?.(35, -10); // one antenna up
break;
case 'breathe':
// Slow rhythmic rise/fall — breathing together (grounding)
for (let i = 0; i < 3; i++) {
robot.setHeadRpyDeg?.(0, -8, 0); // inhale
robot.setAntennasDeg?.(10, 10);
await sleep(1800);
robot.setHeadRpyDeg?.(0, 5, 0); // exhale
robot.setAntennasDeg?.(-5, -5);
await sleep(1800);
}
robot.setHeadRpyDeg?.(0, 0, 0);
robot.setAntennasDeg?.(0, 0);
break;
}
}
async function speakWithWiggle(text) {
// Gentle antenna sway while speaking TTS
let going = true;
let i = 0;
const sway = (async () => {
while (going) {
robot?.setAntennasDeg?.(i % 2 === 0 ? 10 : -10, i % 2 === 0 ? -10 : 10);
i++;
await sleep(380);
}
})();
await speak(text);
going = false;
await sway;
robot?.setAntennasDeg?.(0, 0);
}
// ════════════════════════════════════════════════════════════════════════════
// TTS — Web Speech API (free, no key needed)
// ════════════════════════════════════════════════════════════════════════════
function speak(text) {
if (!('speechSynthesis' in window)) return Promise.resolve();
speechSynthesis.cancel();
return new Promise(resolve => {
const u = new SpeechSynthesisUtterance(text);
u.rate = 0.95; // slightly slower — therapist pace
u.pitch = 1.05;
u.onend = resolve;
u.onerror = resolve;
speechSynthesis.speak(u);
});
}
// ════════════════════════════════════════════════════════════════════════════
// UI STATE MACHINE
// ════════════════════════════════════════════════════════════════════════════
const MIC_ICONS = {
idle: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="3" width="6" height="12" rx="3" fill="currentColor" stroke="none"/>
<path d="M5 11a7 7 0 0 0 14 0"/>
<line x1="12" y1="18" x2="12" y2="22"/>
</svg>`,
listening: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor" stroke="none"/>
</svg>`,
processing: `<div class="spinner"></div>`,
speaking: `<div class="speak-wave"><span></span><span></span><span></span><span></span><span></span></div>`,
};
const STATE_LABELS = {
idle: 'Ready to listen',
listening: 'Listening…',
processing: 'Reflecting…',
speaking: 'Speaking…',
};
function setState(state) {
appState = state;
const btn = document.getElementById('talk-btn');
const label = document.getElementById('state-label');
const text = document.getElementById('state-text');
btn.dataset.state = state;
btn.innerHTML = MIC_ICONS[state];
btn.disabled = state === 'processing' || state === 'speaking';
text.textContent = STATE_LABELS[state];
label.className = state === 'listening' ? 'listening' : '';
showSpeechLabel(state === 'speaking' ? 'Solace is speaking' : 'Solace');
}
function updateSpeechCard(text) {
const el = document.getElementById('speech-text');
el.style.animation = 'none';
void el.offsetWidth;
el.textContent = text;
el.style.animation = '';
}
function showSpeechLabel(text) {
document.getElementById('speech-label').textContent = text;
}
function updateMoodChip(mood, emoji) {
const m = MOODS[mood] || MOODS.calm;
const chip = document.getElementById('mood-chip');
chip.style.background = m.bg;
chip.style.borderColor = m.border;
chip.style.color = m.color;
document.getElementById('mood-emoji').textContent = emoji;
document.getElementById('mood-label').textContent = mood.charAt(0).toUpperCase() + mood.slice(1);
}
function addTranscriptRow(who, text) {
const row = document.createElement('div');
row.className = 't-row';
const whoEl = document.createElement('div');
whoEl.className = 'who' + (who === 'solace' ? ' solace' : '');
whoEl.textContent = who === 'solace' ? 'Solace' : who === 'user' ? 'You' : '·';
const p = document.createElement('p');
p.textContent = text;
row.appendChild(whoEl);
row.appendChild(p);
const t = document.getElementById('transcript');
t.appendChild(row);
t.scrollTop = t.scrollHeight;
}
// ─── Session timer ────────────────────────────────────────────────────────
function startSessionTimer() {
sessionStartTime = Date.now();
timerInterval = setInterval(() => {
const s = Math.floor((Date.now() - sessionStartTime) / 1000);
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const ss = String(s % 60).padStart(2, '0');
document.getElementById('timer-text').textContent = `${hh}:${mm}:${ss}`;
}, 1000);
}
// ─── Helpers ──────────────────────────────────────────────────────────────
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
</script>
</body>
</html>