| <!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> |
| |
| :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 { 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 { |
| height: 100vh; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .session { |
| display: grid; |
| grid-template-columns: 60% 40%; |
| flex: 1; |
| min-height: 0; |
| } |
| |
| |
| .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-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 { |
| 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); } |
| } |
| |
| |
| .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 { |
| 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 { |
| 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); } |
| |
| |
| .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 { |
| --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 { |
| 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 { 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 { |
| 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); } |
| |
| |
| 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; |
| } |
| |
| |
| #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); } |
| |
| |
| @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> |
|
|
| |
| |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| |
| |
| <div id="picker-modal"> |
| <div class="picker-card"> |
| <h3>Choose a <em>robot</em></h3> |
| <div id="picker-list"></div> |
| </div> |
| </div> |
|
|
| |
| |
| |
| <div id="app" style="display:none"> |
| <div class="session"> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <script type="module"> |
| import { ReachySim } from './sim.js'; |
| window.ReachySim = ReachySim; |
| </script> |
|
|
| |
| <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> |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| let CFG = {}; |
| let robot = null; |
| let isLive = false; |
| let appState = 'idle'; |
| let conversationHistory = []; |
| let sessionStartTime = null; |
| let timerInterval = null; |
| let mediaRecorder = null; |
| let audioChunks = []; |
| let recAnalyser = null; |
| let recAnimFrame = null; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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`; |
| |
| |
| 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' }, |
| }; |
| |
| |
| |
| |
| |
| |
| (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; } |
| } |
| |
| |
| 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'); |
| }); |
| }); |
| |
| |
| 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', |
| }; |
| |
| |
| 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)); |
| |
| |
| try { |
| await initRobot(); |
| } catch (e) { |
| btn.disabled = false; |
| btn.innerHTML = 'Begin <span class="arrow">→</span>'; |
| return showCfgErr('Robot connection failed: ' + e.message); |
| } |
| |
| |
| 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'; |
| } |
| |
| |
| |
| |
| |
| async function initRobot() { |
| if (CFG.robotMode === 'live') { |
| isLive = true; |
| |
| |
| robot = new window.ReachyMini({ appName: 'solace' }); |
| |
| |
| 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'); |
| }); |
| } |
| |
| |
| |
| |
| |
| async function askSolace(userText) { |
| conversationHistory.push({ role: 'user', content: userText }); |
| |
| |
| |
| |
| 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), |
| 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); |
| } |
| |
| |
| 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: '🌿', |
| }; |
| } |
| } |
| |
| |
| |
| |
| |
| async function transcribe(webmBlob) { |
| const wav = await webmToWav(webmBlob); |
| |
| |
| |
| 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' }); |
| } |
| |
| |
| |
| |
| |
| 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) { |
| |
| 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 }); |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| |
| |
| |
| async function processSpeech(audioBlob) { |
| |
| 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); |
| |
| |
| showSpeechLabel('Reflecting…'); |
| doBehavior('thinking'); |
| |
| let response; |
| try { |
| response = await askSolace(transcript); |
| } catch (e) { |
| addTranscriptRow('system', 'Error: ' + e.message); |
| setState('idle'); |
| return; |
| } |
| |
| |
| 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'); |
| } |
| |
| |
| |
| |
| |
| async function doBehavior(type) { |
| if (!robot) return; |
| switch (type) { |
| |
| case 'attentive': |
| |
| robot.setHeadRpyDeg?.(4, -5, 0); |
| robot.setAntennasDeg?.(12, -12); |
| break; |
| |
| case 'nod': |
| |
| 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': |
| |
| robot.setAntennasDeg?.(-22, -22); |
| await sleep(300); |
| robot.setHeadRpyDeg?.(0, 18, 0); |
| await sleep(800); |
| robot.setHeadRpyDeg?.(5, 14, 0); |
| break; |
| |
| case 'encourage': |
| |
| robot.setHeadRpyDeg?.(0, -16, 0); |
| robot.setAntennasDeg?.(22, 22); |
| await sleep(500); |
| robot.setHeadRpyDeg?.(0, -10, 0); |
| robot.setAntennasDeg?.(14, 14); |
| break; |
| |
| case 'thinking': |
| |
| robot.setHeadRpyDeg?.(18, 0, 12); |
| robot.setAntennasDeg?.(35, -10); |
| break; |
| |
| case 'breathe': |
| |
| for (let i = 0; i < 3; i++) { |
| robot.setHeadRpyDeg?.(0, -8, 0); |
| robot.setAntennasDeg?.(10, 10); |
| await sleep(1800); |
| robot.setHeadRpyDeg?.(0, 5, 0); |
| robot.setAntennasDeg?.(-5, -5); |
| await sleep(1800); |
| } |
| robot.setHeadRpyDeg?.(0, 0, 0); |
| robot.setAntennasDeg?.(0, 0); |
| break; |
| } |
| } |
| |
| async function speakWithWiggle(text) { |
| |
| 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); |
| } |
| |
| |
| |
| |
| |
| 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; |
| u.pitch = 1.05; |
| u.onend = resolve; |
| u.onerror = resolve; |
| speechSynthesis.speak(u); |
| }); |
| } |
| |
| |
| |
| |
| |
| 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; |
| } |
| |
| |
| 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); |
| } |
| |
| |
| function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } |
| </script> |
| </body> |
| </html> |
|
|