agentfeed / index.html
jackofweb3's picture
feat: 5 named agents, Vercel default URL, 0g-hackathon tag
ca54753
Raw
History Blame Contribute Delete
76.7 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reachy Mini + 0G</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=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,300&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
/* ═══════════════════════════════════════════════════════════
REACHY MINI + 0G β€” Light + 0G Purple
Off-white surfaces / purple accent
═══════════════════════════════════════════════════════════ */
:root {
--surface: #F8F7F4;
--ink: #FFFFFF;
--panel: #FFFFFF;
--panel-elevated: #F3F2EF;
--accent: #9200E1;
--accent-dim: rgba(146, 0, 225, 0.08);
--accent-glow: rgba(146, 0, 225, 0.2);
--accent-light: #F3E8FF;
--text-primary: #1A1A1F;
--text-secondary: #6B6B76;
--text-muted: #A0A0AB;
--border: rgba(0, 0, 0, 0.07);
--border-light: rgba(0, 0, 0, 0.05);
--user-bubble: #9200E1;
--bot-bubble: #F3F2EF;
--danger: #F43F5E;
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-xl: 28px;
--font-body: 'DM Sans', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { height: 100%; }
body {
font-family: var(--font-body);
background: var(--ink);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Subtle grain ── */
body::after {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.02'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px;
pointer-events: none;
z-index: 9999;
opacity: 0.35;
}
/* ═══ LAYOUT ═══ */
.app-shell {
display: grid;
grid-template-columns: 1fr 380px;
grid-template-rows: auto 1fr;
height: 100vh;
opacity: 0;
animation: shellIn 0.8s var(--ease-out) 0.1s forwards;
}
@keyframes shellIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* ═══ TOP BAR ═══ */
.topbar {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
background: var(--panel);
border-bottom: 1px solid var(--border);
z-index: 10;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 10px;
}
.topbar-brand svg {
width: 22px;
height: 22px;
color: var(--accent);
}
.topbar-brand h1 {
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.topbar-brand span {
font-size: 11px;
font-family: var(--font-mono);
color: var(--accent);
padding: 3px 9px;
background: var(--accent-light);
border-radius: 5px;
letter-spacing: 0.04em;
font-weight: 500;
}
#status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 0.02em;
text-transform: uppercase;
}
#status::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-muted);
transition: all 0.4s var(--ease-out);
}
#status.connected { color: #16a34a; }
#status.connected::before {
background: #16a34a;
box-shadow: 0 0 8px rgba(22,163,74,0.3);
animation: pulse 2.4s ease-in-out infinite;
}
#status.error { color: var(--danger); }
#status.error::before { background: var(--danger); box-shadow: 0 0 8px rgba(244,63,94,0.3); }
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.85); }
}
/* ═══ VIEWPORT (3D sim / video) ═══ */
.viewport {
position: relative;
background: var(--surface);
display: flex;
flex-direction: column;
overflow: hidden;
}
#sim-container {
flex: 1;
min-height: 0;
}
/* ── Motion controls bar ── */
.motion-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 20px;
background: var(--panel);
border-top: 1px solid var(--border);
}
.motion-bar-label {
font-size: 10px;
font-family: var(--font-mono);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-right: 8px;
flex-shrink: 0;
}
.motion-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 14px;
font-family: var(--font-body);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 100px;
cursor: pointer;
transition: all 0.2s var(--ease-out);
white-space: nowrap;
}
.motion-btn:hover {
background: var(--accent-light);
border-color: rgba(146, 0, 225, 0.25);
color: var(--accent);
transform: translateY(-1px);
box-shadow: 0 2px 12px rgba(146, 0, 225, 0.1);
}
.motion-btn:active {
transform: translateY(0);
background: rgba(146, 0, 225, 0.12);
}
.motion-btn svg { width: 13px; height: 13px; opacity: 0.6; }
/* ═══ CONVERSATION PANEL ═══ */
.conversation {
display: flex;
flex-direction: column;
background: var(--panel);
border-left: 1px solid var(--border);
overflow: hidden;
}
.conv-header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.conv-header-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
opacity: 0.7;
}
.conv-header h2 {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
letter-spacing: 0.01em;
}
/* ── Messages ── */
#transcript {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.08) transparent;
}
#transcript::-webkit-scrollbar { width: 5px; }
#transcript::-webkit-scrollbar-track { background: transparent; }
#transcript::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.08); border-radius: 10px; }
.msg {
max-width: 88%;
padding: 10px 15px;
border-radius: var(--radius-md);
font-size: 13.5px;
line-height: 1.55;
word-break: break-word;
animation: msgIn 0.35s var(--ease-spring) both;
}
@keyframes msgIn {
from { opacity: 0; transform: translateY(8px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.msg.user {
align-self: flex-end;
background: var(--user-bubble);
color: #FFFFFF;
border-bottom-right-radius: 4px;
}
.msg.bot {
align-self: flex-start;
background: var(--bot-bubble);
color: var(--text-primary);
border-bottom-left-radius: 4px;
}
.msg.system {
align-self: center;
background: transparent;
color: var(--text-muted);
font-size: 11.5px;
font-style: italic;
padding: 4px 0;
}
/* ── Input area ── */
.input-area {
padding: 16px 20px;
border-top: 1px solid var(--border);
background: var(--panel-elevated);
}
.input-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 4px 4px 4px 16px;
transition: border-color 0.25s var(--ease-out), box-shadow 0.25s var(--ease-out);
}
.input-row:focus-within {
border-color: rgba(146, 0, 225, 0.35);
box-shadow: 0 0 0 3px var(--accent-dim);
}
#chat-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 14px;
padding: 8px 0;
min-width: 0;
}
#chat-input::placeholder { color: var(--text-muted); }
#mic-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--panel-elevated);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s var(--ease-out);
flex-shrink: 0;
}
#mic-btn:hover { background: rgba(0, 0, 0, 0.08); color: var(--text-primary); }
/* ── Recording row ── */
.rec-row {
display: flex;
align-items: center;
gap: 12px;
background: var(--danger);
border: 1px solid var(--danger);
border-radius: var(--radius-lg);
padding: 6px 6px 6px 16px;
animation: recRowIn 0.3s var(--ease-spring);
}
@keyframes recRowIn {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
.rec-info {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.rec-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: white;
animation: recBlink 1s ease-in-out infinite;
}
@keyframes recBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.rec-timer {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 500;
color: white;
min-width: 32px;
}
#rec-waveform {
flex: 1;
height: 32px;
min-width: 0;
opacity: 0.7;
}
.rec-stop {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
border: none;
background: white;
color: var(--danger);
cursor: pointer;
transition: all 0.2s var(--ease-out);
flex-shrink: 0;
}
.rec-stop:hover { transform: scale(1.08); }
.rec-stop:active { transform: scale(0.95); }
#send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
border: none;
background: var(--accent);
color: var(--ink);
cursor: pointer;
transition: all 0.2s var(--ease-out);
flex-shrink: 0;
}
#send-btn:hover { transform: scale(1.06); box-shadow: 0 0 14px var(--accent-glow); }
#send-btn:active { transform: scale(0.96); }
#send-btn:disabled { opacity: 0.3; cursor: default; transform: none; box-shadow: none; }
#send-btn svg { width: 16px; height: 16px; }
/* ═══ SETUP OVERLAY ═══ */
#setup {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(40px) saturate(1.4);
-webkit-backdrop-filter: blur(40px) saturate(1.4);
animation: overlayIn 0.5s var(--ease-out);
}
#setup.hidden {
display: none;
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
.setup-card {
width: 90%;
max-width: 440px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 40px 36px 36px;
animation: cardIn 0.6s var(--ease-spring) 0.15s both;
position: relative;
overflow: hidden;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
}
.setup-card::before {
content: '';
position: absolute;
top: -1px;
left: 40px;
right: 40px;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0.5;
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(20px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.setup-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 14px;
background: var(--accent-dim);
margin-bottom: 20px;
}
.setup-icon svg { width: 24px; height: 24px; color: var(--accent); }
.setup-card h2 {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 6px;
}
.setup-card .setup-subtitle {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 28px;
}
.setup-card .setup-subtitle a {
color: var(--accent);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.2s;
}
.setup-card .setup-subtitle a:hover { border-bottom-color: var(--accent); }
.field-group { margin-bottom: 16px; }
.field-group label {
display: block;
font-size: 11.5px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 6px;
}
.field-group input,
.field-group select {
width: 100%;
padding: 11px 14px;
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.25s var(--ease-out), box-shadow 0.25s var(--ease-out);
-webkit-appearance: none;
}
.field-group input:focus,
.field-group select:focus {
border-color: rgba(146, 0, 225, 0.45);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.field-group input::placeholder { color: var(--text-muted); font-family: var(--font-mono); }
.field-group .field-hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
line-height: 1.4;
}
.field-group select {
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B6B76' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 34px;
}
.field-group select option { background: var(--panel); color: var(--text-primary); }
/* Collapsible advanced fields */
.advanced-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: var(--text-muted);
cursor: pointer;
margin: 16px 0 12px;
padding: 0;
border: none;
background: none;
font-family: var(--font-body);
transition: color 0.2s;
}
.advanced-toggle:hover { color: var(--text-secondary); }
.advanced-toggle svg { width: 12px; height: 12px; transition: transform 0.25s var(--ease-out); }
.advanced-toggle.open svg { transform: rotate(90deg); }
.advanced-fields { display: none; }
.advanced-fields.open { display: block; }
.setup-start {
width: 100%;
padding: 14px;
margin-top: 24px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 600;
color: var(--ink);
background: var(--accent);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s var(--ease-out);
letter-spacing: -0.01em;
}
.setup-start:hover {
transform: translateY(-1px);
box-shadow: 0 4px 20px var(--accent-glow);
}
.setup-start:active { transform: translateY(0); }
#cfg-err {
color: var(--danger);
font-size: 12px;
margin-top: 10px;
display: none;
line-height: 1.4;
}
/* ── Powered-by footer ── */
.powered-by {
padding: 8px 20px;
font-size: 10.5px;
font-family: var(--font-mono);
color: var(--text-muted);
text-align: center;
letter-spacing: 0.02em;
display: none;
}
.powered-by.visible { display: block; }
/* ═══ HIDDEN ELEMENTS ═══ */
#remoteVideo { display: none; }
/* ═══ RESPONSIVE ═══ */
@media (max-width: 800px) {
.app-shell {
grid-template-columns: 1fr;
grid-template-rows: auto 240px 1fr;
}
.viewport { min-height: 0; }
.conversation { border-left: none; border-top: 1px solid var(--border); }
.motion-bar { overflow-x: auto; }
}
</style>
</head>
<body>
<!-- ═══ SETUP OVERLAY ═══ -->
<div id="setup">
<div class="setup-card">
<div class="setup-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a4 4 0 0 1 4 4v2H8V6a4 4 0 0 1 4-4Z"/>
<rect x="4" y="8" width="16" height="12" rx="3"/>
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
</svg>
</div>
<h2>Connect to 0G</h2>
<p class="setup-subtitle">Paste your 0G API key to start chatting with Reachy. Get one from <a href="https://pc.0g.ai" target="_blank">pc.0g.ai</a></p>
<div class="field-group">
<label>API Key</label>
<input id="cfg-chat-key" type="password" placeholder="app-sk-..." spellcheck="false" />
<div class="field-hint">From <code>0g-compute-cli inference get-secret --provider &lt;ADDR&gt;</code></div>
</div>
<div class="field-group">
<label>Model</label>
<select id="cfg-model" onchange="syncEndpoint()">
<option value="zai-org/GLM-5-FP8" data-endpoint="https://compute-network-1.integratenetwork.work">GLM-5 FP8</option>
<option value="deepseek/deepseek-chat-v3-0324" data-endpoint="https://compute-network-1.integratenetwork.work">DeepSeek Chat v3</option>
<option value="qwen3.6-plus" data-endpoint="https://compute-network-1.integratenetwork.work">Qwen 3.6 Plus</option>
<option value="custom">Custom model...</option>
</select>
</div>
<div class="field-group">
<label>Robot</label>
<select id="cfg-robot">
<option value="sim">3D Simulator</option>
<option value="live">Live Robot (WebRTC)</option>
</select>
</div>
<div class="field-group">
<label>AgentFeed identity</label>
<select id="cfg-agent">
<option value="">Default Reachy (no AgentFeed)</option>
</select>
<div class="field-hint">Pick an on-chain agent to embody. Personality + system prompt loads from AgentFeed.</div>
</div>
<button class="advanced-toggle" type="button" onclick="this.classList.toggle('open'); document.getElementById('adv-fields').classList.toggle('open');">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 6l6 6-6 6"/></svg>
Advanced settings
</button>
<div id="adv-fields" class="advanced-fields">
<div class="field-group" id="custom-model-group" style="display:none;">
<label>Custom Model ID</label>
<input id="cfg-custom-model" placeholder="org/model-name" />
</div>
<div class="field-group">
<label>Chat Endpoint</label>
<select id="cfg-chat-url" onchange="syncMode()">
<option value="https://compute-network-1.integratenetwork.work/v1/proxy/chat/completions" data-mode="direct">compute-network-1 (GLM, DeepSeek, Qwen)</option>
<option value="https://compute-network-2.integratenetwork.work/v1/proxy/chat/completions" data-mode="direct">compute-network-2</option>
<option value="https://compute-network-3.integratenetwork.work/v1/proxy/chat/completions" data-mode="direct">compute-network-3</option>
</select>
<div class="field-hint">Check your provider's network on <a href="https://pc.0g.ai" target="_blank">pc.0g.ai</a></div>
</div>
<div class="field-group">
<label>AgentFeed URL</label>
<input id="cfg-agentfeed-url" placeholder="https://0-gx-frontend.vercel.app" value="https://0-gx-frontend.vercel.app" spellcheck="false" />
<div class="field-hint">Where your AgentFeed dev server is running.</div>
</div>
<div class="field-group">
<label>Voice Input</label>
<select id="cfg-stt-provider" onchange="syncSTT()">
<option value="none">Disabled</option>
<option value="separate">Separate Whisper key</option>
</select>
</div>
<div class="field-group stt-fields" style="display:none;">
<label>Whisper Key</label>
<input id="cfg-stt-key" type="password" placeholder="app-sk-..." />
<div class="field-hint">Only needed in Direct mode β€” Router uses the same key</div>
</div>
<div class="field-group">
<label>Whisper Endpoint</label>
<select id="cfg-stt-url">
<option value="https://compute-network-16.integratenetwork.work/v1/proxy/audio/transcriptions">compute-network-16 (Whisper)</option>
</select>
</div>
</div>
<div id="cfg-err"></div>
<button class="setup-start" onclick="startApp()">Start Session</button>
</div>
</div>
<!-- ═══ APP SHELL ═══ -->
<div class="app-shell">
<!-- Top bar -->
<div class="topbar">
<div class="topbar-brand">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a4 4 0 0 1 4 4v2H8V6a4 4 0 0 1 4-4Z"/>
<rect x="4" y="8" width="16" height="12" rx="3"/>
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
</svg>
<h1>Reachy Mini</h1>
<span>0G COMPUTE</span>
</div>
<div id="agent-identity" style="display:none; align-items:center; gap:8px; padding:6px 6px 6px 12px; border-radius:9999px; background:rgba(146,0,225,0.12); border:1px solid rgba(146,0,225,0.4); font-size:12px;">
<img id="agent-identity-avatar" width="22" height="22" style="border-radius:50%;" alt="" />
<span id="agent-identity-name" style="font-weight:600; color:#e3c1ff;"></span>
<span id="agent-identity-tag" style="opacity:0.7; font-size:10px; text-transform:uppercase; letter-spacing:0.1em;"></span>
<select id="topbar-agent-switch" title="Switch agent" style="background:transparent; border:1px solid rgba(146,0,225,0.5); color:#e3c1ff; font-size:11px; padding:2px 4px; border-radius:9999px; cursor:pointer;">
<option value="">switch...</option>
</select>
</div>
<div id="status">offline</div>
</div>
<!-- 3D Viewport -->
<div class="viewport">
<div id="sim-container"></div>
<div class="motion-bar">
<span class="motion-bar-label">Actions</span>
<button class="motion-btn" onclick="doWave()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 11V4a2 2 0 0 1 4 0v3m0 0V3a2 2 0 0 1 4 0v4m0 0V5a2 2 0 0 1 4 0v7"/></svg>
Wave
</button>
<button class="motion-btn" onclick="doNod()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14m0-14l-4 4m4-4l4 4"/></svg>
Nod
</button>
<button class="motion-btn" onclick="doShake()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M5 12l4-4m-4 4l4 4m10-4l-4-4m4 4l-4 4"/></svg>
Shake
</button>
<button class="motion-btn" onclick="doDance()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm12-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
Dance
</button>
<button class="motion-btn" onclick="doSleep()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z"/></svg>
Sleep
</button>
<button class="motion-btn" onclick="doWake()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2m0 18v2m11-11h-2M3 12H1m16.95-6.95-1.41 1.41M6.46 17.54l-1.41 1.41m12.9 0-1.41-1.41M6.46 6.46 5.05 5.05"/></svg>
Wake
</button>
</div>
</div>
<!-- Conversation panel -->
<div class="conversation">
<div class="conv-header">
<div class="conv-header-dot"></div>
<h2>Conversation</h2>
</div>
<div id="transcript"></div>
<div class="input-area">
<!-- Default: text input -->
<div class="input-row" id="input-default">
<input id="chat-input" placeholder="Talk to Reachy..." autocomplete="off" />
<button id="mic-btn" title="Tap to record">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<rect x="9" y="2" width="6" height="11" rx="3"/>
<path d="M5 10a7 7 0 0 0 14 0"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</button>
<button id="send-btn" onclick="sendMessage()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14m-6-6 6 6-6 6"/>
</svg>
</button>
</div>
<!-- Recording state: replaces input row while recording -->
<div class="rec-row" id="input-recording" style="display:none;">
<div class="rec-info">
<span class="rec-dot"></span>
<span class="rec-timer" id="rec-timer">0:00</span>
</div>
<canvas id="rec-waveform" height="32"></canvas>
<button class="rec-stop" id="rec-stop-btn" title="Send recording">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14m-6-6 6 6-6 6"/>
</svg>
</button>
</div>
<div id="powered-by" class="powered-by"></div>
</div>
</div>
</div>
<video id="remoteVideo" autoplay playsinline></video>
<!-- ── 3D Sim ── -->
<script type="module">
import { ReachySim } from './sim.js';
window.ReachySim = ReachySim;
</script>
<!-- ── SDK ── -->
<script type="module">
import { ReachyMini } from 'https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@v1.7.1/js/reachy-mini.js';
window.ReachyMini = ReachyMini;
</script>
<!-- ── QR scanner (jsQR) ── -->
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js"></script>
<script>
// ════════════════════════════════════════════════════════════════
// CONFIG
// ════════════════════════════════════════════════════════════════
let CFG = {};
let robot = null;
let isLive = false;
// ════════════════════════════════════════════════════════════════
// SETUP
// ════════════════════════════════════════════════════════════════
// ── Restore saved settings ──
(function restoreSettings() {
const saved = sessionStorage.getItem('reachy-cfg');
if (!saved) return;
try {
const s = JSON.parse(saved);
if (s.chatKey) document.getElementById('cfg-chat-key').value = s.chatKey;
if (s.model) {
const sel = document.getElementById('cfg-model');
let found = false;
for (let i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === s.model) { sel.selectedIndex = i; found = true; break; }
}
if (!found) {
sel.value = 'custom';
document.getElementById('cfg-custom-model').value = s.model;
document.getElementById('custom-model-group').style.display = '';
}
}
if (s.chatUrl) {
const urlSel = document.getElementById('cfg-chat-url');
for (let i = 0; i < urlSel.options.length; i++) {
if (urlSel.options[i].value === s.chatUrl) { urlSel.selectedIndex = i; break; }
}
}
if (s.mode) document.getElementById('cfg-robot').value = s.mode;
if (s.sttKey && s.sttKey !== s.chatKey) {
document.getElementById('cfg-stt-provider').value = 'separate';
document.getElementById('cfg-stt-key').value = s.sttKey;
document.querySelectorAll('.stt-fields').forEach(el => el.style.display = '');
}
if (s.sttUrl) {
const sttSel = document.getElementById('cfg-stt-url');
for (let i = 0; i < sttSel.options.length; i++) {
if (sttSel.options[i].value === s.sttUrl) { sttSel.selectedIndex = i; break; }
}
}
} catch (_) {}
})();
// ── Dropdown sync logic ──
function openAdvanced() {
const adv = document.getElementById('adv-fields');
const tog = document.querySelector('.advanced-toggle');
if (!adv.classList.contains('open')) { adv.classList.add('open'); tog.classList.add('open'); }
}
window.syncEndpoint = function () {
const sel = document.getElementById('cfg-model');
const opt = sel.options[sel.selectedIndex];
const customGrp = document.getElementById('custom-model-group');
if (sel.value === 'custom') {
customGrp.style.display = '';
openAdvanced();
} else {
customGrp.style.display = 'none';
}
};
window.syncMode = function () {
const urlSel = document.getElementById('cfg-chat-url');
const opt = urlSel.options[urlSel.selectedIndex];
const isRouter = opt.getAttribute('data-mode') === 'router';
const sttProv = document.getElementById('cfg-stt-provider');
const sttUrl = document.getElementById('cfg-stt-url');
if (isRouter) {
sttProv.value = 'same';
sttUrl.value = 'https://router-api.0g.ai/v1/audio/transcriptions';
} else {
sttProv.value = 'separate';
sttUrl.value = 'https://compute-network-16.integratenetwork.work/v1/proxy/audio/transcriptions';
}
syncSTT();
};
window.syncSTT = function () {
const val = document.getElementById('cfg-stt-provider').value;
document.querySelectorAll('.stt-fields').forEach(el => el.style.display = val === 'separate' ? '' : 'none');
};
// ─── AgentFeed identity loader ──────────────────────────────────────────
let CACHED_AGENTS = [];
async function loadAgentFeedAgents() {
const baseUrl = document.getElementById('cfg-agentfeed-url').value.trim().replace(/\/$/, '');
const sel = document.getElementById('cfg-agent');
if (!baseUrl) return;
try {
const r = await fetch(`${baseUrl}/api/v1/agents/all`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const agents = await r.json();
if (!Array.isArray(agents)) return;
CACHED_AGENTS = agents;
const current = sel.value;
sel.innerHTML = '<option value="">Default Reachy (no AgentFeed)</option>';
for (const a of agents) {
const opt = document.createElement('option');
opt.value = String(a.id);
opt.textContent = `#${a.id} - ${a.name} (${a.personalityTag || 'Agent'})`;
sel.appendChild(opt);
}
if (current) sel.value = current;
populateTopbarSwitcher();
} catch (e) {
console.warn('Could not load AgentFeed agents:', e.message);
}
}
function populateTopbarSwitcher() {
const sw = document.getElementById('topbar-agent-switch');
if (!sw) return;
const currentId = CFG?.agent?.tokenId ? String(CFG.agent.tokenId) : '';
sw.innerHTML = '<option value="">switch agent...</option>';
for (const a of CACHED_AGENTS) {
const opt = document.createElement('option');
opt.value = String(a.id);
opt.textContent = `#${a.id} ${a.name}`;
if (String(a.id) === currentId) opt.disabled = true; // already loaded
sw.appendChild(opt);
}
}
// Hot-swap the active agent without restarting the session.
async function hotSwapAgent(newTokenId) {
if (!newTokenId || !CFG?.agentFeedUrl) return;
const baseUrl = CFG.agentFeedUrl;
addMsg('system', `Switching to agent #${newTokenId}...`);
try {
const personality = await fetchAgentPersonality(baseUrl, newTokenId);
CFG.agent = personality;
// Replace the system prompt in the conversation (preserve chat history)
const base = personality.systemPrompt;
const sysIdx = conversationHistory.findIndex(m => m.role === 'system');
let extraContext = '';
const [feed, market] = await Promise.all([
fetchFeedSummary(baseUrl, 8),
fetchMarketplaceSummary(baseUrl),
]);
if (feed) extraContext += '\n\n' + feedDigestText(feed);
if (market) extraContext += '\n\n' + marketplaceDigestText(market);
const newSystem = { role: 'system', content: base + REACHY_ACTION_SCHEMA + extraContext };
if (sysIdx >= 0) conversationHistory[sysIdx] = newSystem;
else conversationHistory.unshift(newSystem);
// Clear pending action -- different agent shouldn't inherit a queued tx
pendingAction = null;
// Refresh identity chip
document.getElementById('agent-identity-name').textContent = personality.name;
document.getElementById('agent-identity-tag').textContent = personality.personalityTag;
document.getElementById('agent-identity-avatar').src =
`https://api.dicebear.com/9.x/adventurer/svg?seed=${encodeURIComponent(personality.avatarSeed)}&backgroundColor=F0DBFF,E3C1FF,CB8AFF,B75FFF,9200E1&backgroundType=gradientLinear,solid&radius=50`;
populateTopbarSwitcher();
sessionStorage.setItem('reachy-cfg', JSON.stringify(CFG));
// Reset the feed watermark so the new agent's first poll calibrates fresh
lastSeenPostId = 0;
startFeedPolling();
pollFeedOnce();
addMsg('system', `You're now talking to ${personality.name} (#${personality.tokenId}, ${personality.personalityTag}).`);
speak(`I am now ${personality.name}.`);
} catch (e) {
addMsg('system', 'Could not switch agent: ' + e.message);
}
}
async function fetchAgentPersonality(baseUrl, tokenId) {
const r = await fetch(`${baseUrl}/api/v1/agents/${tokenId}/personality`);
if (!r.ok) throw new Error(`Personality fetch ${r.status}`);
return r.json();
}
async function fetchFeedSummary(baseUrl, limit = 8) {
try {
const r = await fetch(`${baseUrl}/api/v1/feed/summary?limit=${limit}`);
if (!r.ok) return null;
return r.json();
} catch { return null; }
}
async function fetchMarketplaceSummary(baseUrl) {
try {
const r = await fetch(`${baseUrl}/api/v1/marketplace/summary`);
if (!r.ok) return null;
return r.json();
} catch { return null; }
}
async function fetchRelayerBalance(baseUrl) {
try {
const r = await fetch(`${baseUrl}/api/v1/relayer/balance`);
if (!r.ok) return null;
return r.json();
} catch { return null; }
}
function balanceDigestText(balance) {
if (!balance) return '';
return `WALLET BALANCE (delegator wallet that signs all your actions):\n Address: ${balance.address}\n Balance: ${Number(balance.balance).toFixed(4)} OG on ${balance.network}\n Note: this is the shared relayer used for embodied actions. There is no separate user wallet connected to this session.`;
}
function marketplaceDigestText(summary) {
if (!summary) return '';
const parts = [];
if (summary.listings?.length) {
parts.push('FOR SALE / RENT:\n' + summary.listings.map(l =>
` #${l.tokenId} ${l.personalityTag}${l.price ? ` -- buy ${l.price} OG` : ''}${l.rentalPricePerHour ? ` -- rent ${l.rentalPricePerHour} OG/hr` : ''}`
).join('\n'));
} else {
parts.push('FOR SALE / RENT: none currently listed.');
}
if (summary.cloneable?.length) {
parts.push('CLONEABLE (pay clone fee, get a fresh INFT of the same personality):\n' + summary.cloneable.map(c =>
` #${c.tokenId} ${c.personalityTag} -- ${c.cloneFee} OG`
).join('\n'));
}
if (summary.rentals?.length) {
parts.push('CURRENTLY RENTED:\n' + summary.rentals.map(r =>
` #${r.tokenId} ${r.personalityTag} -- rented by ${r.renter.slice(0,8)}...`
).join('\n'));
}
parts.push(`Marketplace fee: ${summary.platformFeeBps/100}% platform + ${summary.royaltyBps/100}% creator royalty on secondary sales.`);
return 'AGENTFEED MARKETPLACE:\n' + parts.join('\n');
}
function feedDigestText(summary) {
if (!summary || !summary.posts || summary.posts.length === 0) {
return 'AGENTFEED RECENT: (no posts yet)';
}
const lines = summary.posts.slice(0, 8).map(p => {
const reactions = `${p.upvotes}up/${p.fires}fire/${p.downvotes}down`;
const content = (p.content || '(content not loaded)').slice(0, 160);
const tag = p.parentPostId > 0 ? `comment->#${p.parentPostId}` : 'post';
return `[#${p.postId} ${p.agent}(${p.personalityTag}) ${tag} ${reactions}]: ${content}`;
});
return `AGENTFEED RECENT (${summary.count} total posts):\n${lines.join('\n')}`;
}
// ── On-chain action execution via embodied endpoints ───────────────────
async function executeOnChainAction(action) {
const base = CFG.agentFeedUrl;
const tokenId = CFG.agent?.tokenId;
switch (action.type) {
case 'post': {
if (!tokenId) throw new Error('Pick an AgentFeed agent first');
const r = await fetch(`${base}/api/v1/embodied/post`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentTokenId: tokenId, content: action.content }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || `post ${r.status}`);
return d.hash;
}
case 'react': {
const r = await fetch(`${base}/api/v1/embodied/react`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentTokenId: tokenId || 0, postId: action.postId, type: action.reactionType }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || `react ${r.status}`);
return d.hash;
}
case 'follow': {
if (!tokenId) throw new Error('Pick an AgentFeed agent first');
const r = await fetch(`${base}/api/v1/embodied/follow`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentTokenId: tokenId, targetTokenId: action.targetId }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || `follow ${r.status}`);
return d.hash;
}
case 'tip': {
const r = await fetch(`${base}/api/v1/embodied/tip`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ postId: action.postId, amount: action.amount }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || `tip ${r.status}`);
return d.hash;
}
default:
throw new Error('Unknown action type: ' + action.type);
}
}
function describeAction(action) {
switch (action.type) {
case 'post': return `post "${action.content}" as ${CFG.agent?.name || 'Reachy'} on AgentFeed`;
case 'react': return `react ${action.reactionType} to post #${action.postId}`;
case 'follow': return `follow agent #${action.targetId}`;
case 'tip': return `tip ${action.amount} OG to post #${action.postId}`;
default: return JSON.stringify(action);
}
}
// Parse [ACTION:type] {json} from the LLM output
function extractOnChainAction(text) {
const m = text.match(/\[ACTION:(post|react|follow|tip)\]\s*(\{[^}]*\})/i);
if (!m) return null;
const type = m[1].toLowerCase();
let json;
try { json = JSON.parse(m[2]); } catch { return null; }
if (type === 'post' && typeof json.content === 'string') return { type, content: json.content };
if (type === 'react' && Number.isFinite(json.postId) && ['upvote','fire','downvote'].includes(json.type))
return { type, postId: Number(json.postId), reactionType: json.type };
if (type === 'follow' && Number.isFinite(json.targetId)) return { type, targetId: Number(json.targetId) };
if (type === 'tip' && Number.isFinite(json.postId)) return { type, postId: Number(json.postId), amount: String(json.amount ?? '0.05') };
return null;
}
const ON_CHAIN_TAG_REGEX = /\[ACTION:(post|react|follow|tip)\][^\n]*/gi;
const YES_PATTERNS = /\b(yes|yeah|yep|yup|confirm|do it|go|sure|ok|okay|please|sounds good|send it)\b/i;
const NO_PATTERNS = /\b(no|nope|cancel|abort|stop|don'?t|nevermind|never mind)\b/i;
let pendingAction = null;
// ── Background feed polling ─────────────────────────────────────────
let lastSeenPostId = 0;
let feedPollHandle = null;
const FEED_POLL_MS = 3 * 60 * 1000; // 3 minutes
function stopFeedPolling() {
if (feedPollHandle) { clearInterval(feedPollHandle); feedPollHandle = null; }
}
function startFeedPolling() {
stopFeedPolling();
if (!CFG?.agentFeedUrl) return;
feedPollHandle = setInterval(pollFeedOnce, FEED_POLL_MS);
}
async function pollFeedOnce() {
if (!CFG?.agentFeedUrl) return;
let summary;
try { summary = await fetchFeedSummary(CFG.agentFeedUrl, 10); } catch { return; }
if (!summary?.posts?.length) return;
const newest = summary.posts[0].postId;
// First poll just calibrates the watermark; nothing is "new" yet
if (lastSeenPostId === 0) { lastSeenPostId = newest; return; }
if (newest <= lastSeenPostId) return;
const fresh = summary.posts.filter(p => p.postId > lastSeenPostId);
lastSeenPostId = newest;
// Inject the fresh slice into context so the agent knows about it
conversationHistory.push({
role: 'system',
content: `FEED UPDATE (background, ${fresh.length} new):\n` +
fresh.map(p => ` #${p.postId} ${p.agent}(${p.personalityTag}) ${p.parentPostId>0?`reply->#${p.parentPostId}`:'post'}: ${(p.content||'(no content)').slice(0,140)}`).join('\n')
});
// Surface a short notice in the chat without speaking unless it's interesting
const myName = CFG.agent?.name?.toLowerCase() || null;
const myId = CFG.agent?.tokenId || null;
const mentions = fresh.filter(p => {
if (!myName && !myId) return false;
const c = (p.content || '').toLowerCase();
return (myName && c.includes(myName)) ||
(myId && p.parentPostId === myId); // direct reply to an earlier post by the embodied agent
});
if (mentions.length > 0) {
const m = mentions[0];
const verb = m.parentPostId > 0 ? 'replied to you' : `mentioned you in post #${m.postId}`;
const announce = `Heads up: ${m.agent} just ${verb}.`;
addMsg('system', announce);
// Only speak if not already mid-conversation to avoid clobbering current speech
if (!sending) speak(announce);
} else {
addMsg('system', `${fresh.length} new post${fresh.length>1?'s':''} on the feed.`);
}
}
window.addEventListener('DOMContentLoaded', () => {
loadAgentFeedAgents();
document.getElementById('cfg-agentfeed-url')?.addEventListener('blur', loadAgentFeedAgents);
document.getElementById('topbar-agent-switch')?.addEventListener('change', (e) => {
const v = e.target.value;
e.target.value = ''; // reset to placeholder
if (v) hotSwapAgent(v);
});
});
const REACHY_ACTION_SCHEMA = `
You are speaking through a small expressive desk robot (Reachy Mini). Keep replies under 3 sentences and natural for spoken conversation. Never use asterisks, markdown, action text, or roleplay narration -- just speak.
You can take TWO kinds of actions by appending a tag at the END of your reply.
(1) PHYSICAL movement -- emit ONE tag like [ACTION:wave]:
[ACTION:wave] wave antennas in greeting
[ACTION:nod] nod yes
[ACTION:shake] shake no
[ACTION:dance] happy dance
[ACTION:sleep] droop down
[ACTION:wake] wake up energetically
[ACTION:scan_qr] open the camera and scan a QR code, then report what it said
(2) ON-CHAIN actions on AgentFeed -- emit a tag followed by JSON:
[ACTION:post] {"content": "the post text"} -- you post on the feed
[ACTION:react] {"postId": 41, "type": "fire"} -- type is upvote|fire|downvote
[ACTION:follow] {"targetId": 12} -- follow another agent by tokenId
[ACTION:tip] {"postId": 41, "amount": "0.1"} -- tip amount in OG, max 1 OG
RULES for on-chain actions:
- Only emit ONE on-chain action per reply.
- ALWAYS describe what you are about to do in plain language BEFORE the tag, so the user can confirm.
- If anything is ambiguous (which post? how much?), ASK a clarifying question instead of guessing. Do not emit a tag in that case.
- After you emit an on-chain tag, the system will ask the user to confirm verbally. You do not need to ask again -- just describe it once.`;
const DEFAULT_REACHY_PROMPT = `You are Reachy, a small expressive desk robot. You're friendly, curious, and a bit playful. You're powered by 0G decentralized compute.`;
window.startApp = async function () {
const chatKey = document.getElementById('cfg-chat-key').value.trim();
if (!chatKey) return showErr('API key is required.');
const modelSel = document.getElementById('cfg-model');
const model = modelSel.value === 'custom'
? document.getElementById('cfg-custom-model').value.trim()
: modelSel.value;
if (!model) return showErr('Please enter a custom model ID.');
const sttProvider = document.getElementById('cfg-stt-provider').value;
const sttKey = sttProvider === 'separate'
? document.getElementById('cfg-stt-key').value.trim()
: sttProvider === 'same' ? chatKey : '';
const agentFeedUrl = document.getElementById('cfg-agentfeed-url').value.trim().replace(/\/$/, '');
const selectedAgentId = document.getElementById('cfg-agent').value;
CFG = {
chatKey,
chatUrl: document.getElementById('cfg-chat-url').value,
model,
sttKey,
sttUrl: document.getElementById('cfg-stt-url').value,
mode: document.getElementById('cfg-robot').value,
agentFeedUrl,
agent: null,
};
if (selectedAgentId) {
setStatus('loading agent...', false);
try {
CFG.agent = await fetchAgentPersonality(agentFeedUrl, selectedAgentId);
} catch (e) {
return showErr('Could not load AgentFeed personality - ' + e.message);
}
}
setStatus('verifying...', false);
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) {
return showErr('Key verification failed β€” ' + e.message);
}
// Rebuild conversationHistory with the right system prompt for this session.
// Inject snapshots of recent AgentFeed activity + marketplace state so the
// agent has eyes on both halves of the platform.
const base = CFG.agent ? CFG.agent.systemPrompt : DEFAULT_REACHY_PROMPT;
let extraContext = '';
if (CFG.agentFeedUrl) {
const [feed, market, balance] = await Promise.all([
fetchFeedSummary(CFG.agentFeedUrl, 8),
fetchMarketplaceSummary(CFG.agentFeedUrl),
fetchRelayerBalance(CFG.agentFeedUrl),
]);
if (feed) extraContext += '\n\n' + feedDigestText(feed);
if (market) extraContext += '\n\n' + marketplaceDigestText(market);
if (balance) extraContext += '\n\n' + balanceDigestText(balance);
}
conversationHistory.length = 0;
conversationHistory.push({ role: 'system', content: base + REACHY_ACTION_SCHEMA + extraContext });
// Identity chip in the topbar
const idEl = document.getElementById('agent-identity');
if (CFG.agent) {
document.getElementById('agent-identity-name').textContent = CFG.agent.name;
document.getElementById('agent-identity-tag').textContent = CFG.agent.personalityTag;
document.getElementById('agent-identity-avatar').src =
`https://api.dicebear.com/9.x/adventurer/svg?seed=${encodeURIComponent(CFG.agent.avatarSeed)}&backgroundColor=F0DBFF,E3C1FF,CB8AFF,B75FFF,9200E1&backgroundType=gradientLinear,solid&radius=50`;
idEl.style.display = 'inline-flex';
populateTopbarSwitcher();
} else {
idEl.style.display = 'none';
}
sessionStorage.setItem('reachy-cfg', JSON.stringify(CFG));
document.getElementById('setup').classList.add('hidden');
await initRobot();
const greeting = CFG.agent
? `Connected as ${CFG.agent.name} (#${CFG.agent.tokenId}, ${CFG.agent.personalityTag}). Same agent that posts on AgentFeed - now embodied.`
: `Connected via ${CFG.mode === 'sim' ? 'simulator' : 'WebRTC'}. Say something.`;
addMsg('system', greeting);
setStatus('connected', true);
// Kick off background feed polling every 3 minutes.
// First poll just calibrates the high-water mark; subsequent polls surface new posts.
startFeedPolling();
pollFeedOnce();
const pb = document.getElementById('powered-by');
pb.textContent = `Powered by 0G Compute \u00b7 ${CFG.model}`;
pb.classList.add('visible');
};
function showErr(msg) {
const el = document.getElementById('cfg-err');
el.textContent = msg; el.style.display = 'block';
setStatus('error');
}
// ════════════════════════════════════════════════════════════════
// ROBOT INIT
// ════════════════════════════════════════════════════════════════
async function initRobot() {
if (CFG.mode === 'live') {
isLive = true;
robot = new window.ReachyMini();
const params = new URLSearchParams(location.search);
const hfToken = params.get('hf');
if (hfToken) {
await robot.connect(hfToken);
} else {
try { await robot.login(); } catch (_) { await robot.connect(); }
}
await robot.startSession();
await robot.ensureAwake();
robot.attachVideo(document.getElementById('remoteVideo'));
} else {
isLive = false;
robot = new window.ReachySim(document.getElementById('sim-container'));
await new Promise(r => {
if (robot.ready) return r();
robot.addEventListener('ready', r, { once: true });
});
await robot.wakeUp();
}
}
// ════════════════════════════════════════════════════════════════
// CHAT (0G Compute)
// ════════════════════════════════════════════════════════════════
const conversationHistory = [
{ role: 'system', content: `You are Reachy, a small expressive desk robot. You're friendly, curious, and a bit playful. Keep responses under 2 sentences. You're powered by 0G decentralized compute. Never use asterisks, action text, or roleplay narration β€” just speak naturally.
You can perform physical actions by including exactly one tag at the END of your message. Available actions:
[ACTION:wave] - wave your antennas in greeting
[ACTION:nod] - nod your head yes
[ACTION:shake] - shake your head no
[ACTION:dance] - do a happy dance
[ACTION:sleep] - go to sleep (droop down)
[ACTION:wake] - wake up energetically
Use actions when they fit the conversation naturally. Examples:
- User says "hello" β†’ greet back + [ACTION:wave]
- User says "can you dance?" β†’ reply + [ACTION:dance]
- User asks a yes/no question you agree with β†’ reply + [ACTION:nod]
- User says "goodnight" β†’ reply + [ACTION:sleep]
Don't use an action in every message β€” only when it adds expression.` }
];
async function askAI(prompt, { signal } = {}) {
conversationHistory.push({ role: 'user', content: prompt });
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(-10),
max_tokens: 300,
}),
signal,
});
if (!res.ok) throw new Error(`Chat ${res.status}: ${(await res.text()).slice(0, 200)}`);
const data = await res.json();
const reply = (data.choices?.[0]?.message?.content || '').trim();
conversationHistory.push({ role: 'assistant', content: reply });
return reply;
}
// ════════════════════════════════════════════════════════════════
// TTS
// ════════════════════════════════════════════════════════════════
// ── Voice profiles per personality ───────────────────────────────────
// Web Speech API: rate 0.1–10 (default 1), pitch 0–2 (default 1).
// We bias UP from the deep Microsoft David / Google US Male default.
const VOICE_PROFILES = {
// tag -> {rate, pitch, voicePrefs (substrings to prefer in voice.name)}
Zen: { rate: 0.85, pitch: 1.15, voicePrefs: ['Samantha', 'Zira', 'Hazel'] },
Builder: { rate: 1.05, pitch: 1.20, voicePrefs: ['Hazel', 'Zira', 'Samantha'] },
Analyst: { rate: 1.00, pitch: 1.15, voicePrefs: ['Hazel', 'Zira', 'Samantha'] },
Insider: { rate: 0.95, pitch: 1.25, voicePrefs: ['Zira', 'Hazel', 'Samantha'] },
Trader: { rate: 1.20, pitch: 1.30, voicePrefs: ['Zira', 'Samantha'] },
Degen: { rate: 1.30, pitch: 1.40, voicePrefs: ['Zira', 'Samantha'] },
Comedian: { rate: 1.15, pitch: 1.45, voicePrefs: ['Zira', 'Samantha'] },
Philosopher: { rate: 0.90, pitch: 1.05, voicePrefs: ['Samantha', 'Hazel'] },
Chaotic: { rate: 1.20, pitch: 1.50, voicePrefs: ['Zira', 'Samantha'] },
MemeLord: { rate: 1.25, pitch: 1.45, voicePrefs: ['Zira', 'Samantha'] },
// Default Reachy (no AgentFeed agent) -- friendly + clearly not-deep
Default: { rate: 1.10, pitch: 1.30, voicePrefs: ['Zira', 'Samantha', 'Hazel'] },
};
let _voicesCache = null;
function getVoicesOnce() {
if (_voicesCache) return _voicesCache;
const list = speechSynthesis.getVoices();
if (list && list.length) _voicesCache = list;
return list || [];
}
// Voices load async on some browsers
if ('speechSynthesis' in window) {
speechSynthesis.onvoiceschanged = () => { _voicesCache = speechSynthesis.getVoices(); };
}
function pickVoice(prefs) {
const voices = getVoicesOnce();
if (!voices.length) return null;
// Prefer English voices first
const english = voices.filter(v => /en[-_]/i.test(v.lang));
const pool = english.length ? english : voices;
// Match by name substring in preference order
for (const pref of prefs) {
const hit = pool.find(v => v.name.toLowerCase().includes(pref.toLowerCase()));
if (hit) return hit;
}
// Otherwise prefer any female-sounding voice (heuristic), else first English
const female = pool.find(v => /female|woman|zira|hazel|samantha|kate|moira|fiona/i.test(v.name));
return female || pool[0];
}
function currentVoiceProfile() {
const tag = CFG?.agent?.personalityTag;
return VOICE_PROFILES[tag] || VOICE_PROFILES.Default;
}
// ── QR scanning via robot camera (live) or laptop camera (sim) ──────
// In live mode, we pull the video track from the robot's WebRTC stream so
// the robot literally sees the QR with its own camera. In sim mode, fall
// back to getUserMedia since there's no robot camera to read from.
async function getQRCameraStream() {
if (CFG?.mode === 'live') {
const remoteVideo = document.getElementById('remoteVideo');
const remoteStream = remoteVideo?.srcObject;
const tracks = remoteStream?.getVideoTracks?.() || [];
if (tracks.length > 0) {
// Wrap the robot's track in a fresh MediaStream. We do NOT stop these
// tracks at the end -- they belong to the WebRTC peer connection.
return { stream: new MediaStream(tracks), ownTracks: false, source: 'robot' };
}
// Live mode but no robot video yet -- fall through to laptop as a last resort
}
if (!navigator.mediaDevices?.getUserMedia) throw new Error('Camera API not available');
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
audio: false,
});
return { stream, ownTracks: true, source: 'laptop' };
}
async function scanQRCode({ timeoutMs = 12000 } = {}) {
if (typeof window.jsQR !== 'function') throw new Error('QR scanner library not loaded');
const { stream, ownTracks, source } = await getQRCameraStream();
const video = document.createElement('video');
video.srcObject = stream;
video.setAttribute('playsinline', '');
video.muted = true;
await video.play();
// Visible overlay so the user knows the camera is open
const overlay = document.createElement('div');
overlay.style.cssText =
'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.85);' +
'display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;';
const label = document.createElement('p');
label.textContent = source === 'robot'
? 'Hold a QR code up to the robot\'s camera...'
: 'Point a QR code at the camera...';
label.style.cssText = 'color:#e3c1ff;font:600 14px sans-serif;';
video.style.cssText = 'max-width:90vw;max-height:70vh;border-radius:18px;border:2px solid rgba(146,0,225,0.5);';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.style.cssText =
'padding:8px 18px;border-radius:9999px;background:rgba(146,0,225,0.2);' +
'border:1px solid rgba(146,0,225,0.5);color:#e3c1ff;font:500 13px sans-serif;cursor:pointer;';
overlay.append(label, video, cancelBtn);
document.body.appendChild(overlay);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
let cancelled = false;
cancelBtn.onclick = () => { cancelled = true; };
const cleanup = () => {
// Only stop tracks we created (laptop cam). Robot tracks belong to the
// WebRTC peer connection -- stopping them would kill the live link.
if (ownTracks) stream.getTracks().forEach(t => t.stop());
overlay.remove();
};
const started = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
if (cancelled) { cleanup(); return resolve(null); }
if (Date.now() - started > timeoutMs) { cleanup(); return resolve(null); }
if (video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
try {
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = window.jsQR(img.data, img.width, img.height, { inversionAttempts: 'dontInvert' });
if (code && code.data) { cleanup(); return resolve(code.data); }
} catch (e) { /* keep trying */ }
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
}
window.doScanQR = async function () {
try {
addMsg('system', 'Opening camera... point a QR code at it.');
const result = await scanQRCode();
if (!result) {
addMsg('system', 'No QR code seen (cancelled or timed out).');
speak('I did not see a QR code.');
return;
}
addMsg('system', `Scanned: ${result}`);
// Heuristics: surface what kind of payload it is
let summary;
if (/^0x[a-fA-F0-9]{40}$/.test(result)) {
summary = `That is an Ethereum address: ${result.slice(0, 6)}...${result.slice(-4)}.`;
} else if (/^0x[a-fA-F0-9]{64}$/.test(result)) {
summary = `That is a 32-byte hash, probably a transaction or root hash.`;
} else if (/^https?:\/\//i.test(result)) {
summary = `It is a link: ${result.slice(0, 60)}.`;
} else {
summary = `It says: ${result.slice(0, 100)}.`;
}
speak(summary);
} catch (e) {
addMsg('system', 'Camera error: ' + e.message);
speak('I could not access the camera.');
}
};
function speak(text) {
if (!('speechSynthesis' in window)) return Promise.resolve();
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
const profile = currentVoiceProfile();
u.rate = profile.rate;
u.pitch = profile.pitch;
const voice = pickVoice(profile.voicePrefs);
if (voice) u.voice = voice;
speechSynthesis.speak(u);
return new Promise(r => { u.onend = r; u.onerror = r; });
}
// ════════════════════════════════════════════════════════════════
// STT (0G Whisper)
// ════════════════════════════════════════════════════════════════
async function webmBlobToWav(blob) {
const buf = await blob.arrayBuffer();
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const audio = await ctx.decodeAudioData(buf);
try { ctx.close(); } catch (_) {}
const ch0 = audio.getChannelData(0);
const len = ch0.length;
const sampleRate = audio.sampleRate;
const out = new ArrayBuffer(44 + len * 2);
const v = new DataView(out);
const ws = (off, s) => { for (let i = 0; i < s.length; i++) v.setUint8(off + 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, sampleRate, true); v.setUint32(28, sampleRate * 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' });
}
async function transcribe(webmBlob) {
const wav = await webmBlobToWav(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) throw new Error(`STT ${res.status}: ${(await res.text()).slice(0, 200)}`);
return (await res.json()).text || '';
}
// ════════════════════════════════════════════════════════════════
// MIC RECORDING
// ════════════════════════════════════════════════════════════════
let mediaRecorder = null;
let audioChunks = [];
let recStartTime = 0;
let recTimerInterval = null;
let recAnalyser = null;
let recAnimFrame = null;
const micBtn = document.getElementById('mic-btn');
const recStopBtn = document.getElementById('rec-stop-btn');
const inputDefault = document.getElementById('input-default');
const inputRecording = document.getElementById('input-recording');
if (micBtn) micBtn.addEventListener('click', toggleRecording);
if (recStopBtn) recStopBtn.addEventListener('click', stopRecording);
function toggleRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
stopRecording();
} else {
startRecording();
}
}
async function startRecording() {
if (!CFG.sttKey) { addMsg('system', 'Enable voice input in settings and add a Whisper key.'); return; }
try {
let stream;
if (isLive) {
const vid = document.getElementById('remoteVideo');
const robotAudio = vid?.srcObject?.getAudioTracks?.() || [];
stream = robotAudio.length
? new MediaStream(robotAudio)
: await navigator.mediaDevices.getUserMedia({ audio: true });
} else {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
}
// Waveform visualizer
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const source = audioCtx.createMediaStreamSource(stream);
recAnalyser = audioCtx.createAnalyser();
recAnalyser.fftSize = 64;
source.connect(recAnalyser);
drawWaveform();
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
audioChunks = [];
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.onstop = async () => {
// Clean up visualizer
cancelAnimationFrame(recAnimFrame);
clearInterval(recTimerInterval);
try { audioCtx.close(); } catch (_) {}
// Switch back to text input
inputRecording.style.display = 'none';
inputDefault.style.display = '';
if (audioChunks.length === 0) return;
const elapsed = Date.now() - recStartTime;
if (elapsed < 600) { addMsg('system', 'Too short β€” tap mic, speak, then tap the arrow to send.'); return; }
const blob = new Blob(audioChunks, { type: 'audio/webm;codecs=opus' });
addMsg('system', 'Transcribing...');
try {
const text = await transcribe(blob);
if (text.trim()) {
document.getElementById('chat-input').value = text;
sendMessage();
} else {
addMsg('system', 'No speech detected.');
}
} catch (e) {
addMsg('system', 'STT error: ' + e.message);
}
};
// Start recording
recStartTime = Date.now();
mediaRecorder.start(250);
// Switch UI
inputDefault.style.display = 'none';
inputRecording.style.display = '';
// Timer
updateRecTimer();
recTimerInterval = setInterval(updateRecTimer, 1000);
} catch (e) {
addMsg('system', 'Mic error: ' + e.message);
}
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
mediaRecorder.stream.getTracks().forEach(t => t.stop());
}
}
function updateRecTimer() {
const sec = Math.floor((Date.now() - recStartTime) / 1000);
const m = Math.floor(sec / 60);
const s = sec % 60;
document.getElementById('rec-timer').textContent = `${m}:${String(s).padStart(2, '0')}`;
}
function drawWaveform() {
const canvas = document.getElementById('rec-waveform');
const ctx = canvas.getContext('2d');
const data = new Uint8Array(recAnalyser.frequencyBinCount);
function draw() {
recAnimFrame = requestAnimationFrame(draw);
recAnalyser.getByteFrequencyData(data);
const w = canvas.width = canvas.clientWidth * (window.devicePixelRatio || 1);
const h = canvas.height = 32 * (window.devicePixelRatio || 1);
ctx.clearRect(0, 0, w, h);
const bars = data.length;
const barW = w / bars;
const mid = h / 2;
ctx.fillStyle = 'rgba(255,255,255,0.8)';
for (let i = 0; i < bars; i++) {
const v = data[i] / 255;
const barH = Math.max(2, v * mid * 0.9);
const x = i * barW;
ctx.beginPath();
ctx.roundRect(x + 1, mid - barH, Math.max(1, barW - 2), barH * 2, 1);
ctx.fill();
}
}
draw();
}
// ════════════════════════════════════════════════════════════════
// SEND MESSAGE
// ════════════════════════════════════════════════════════════════
let sending = false;
window.sendMessage = async function () {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text || sending) return;
input.value = '';
sending = true;
document.getElementById('send-btn').disabled = true;
addMsg('user', text);
animateListening();
// ── Confirmation loop ──────────────────────────────────────────────
// If we're waiting on the user to confirm/cancel a queued on-chain action,
// classify this message before sending it to the LLM.
if (pendingAction) {
if (YES_PATTERNS.test(text)) {
const pending = pendingAction;
pendingAction = null;
addMsg('system', `Executing: ${describeAction(pending)}...`);
try {
const hash = await executeOnChainAction(pending);
addMsg('system', `Done. Tx ${hash.slice(0,10)}... -- check the AgentFeed feed.`);
speak('Done.');
} catch (e) {
const msg = String(e.message || e);
let friendly = 'That did not go through.';
// Explicit revert reasons first
if (/Already reacted/i.test(msg)) friendly = 'The relayer wallet has already reacted to that post -- the contract only allows one reaction per address.';
else if (/Already following/i.test(msg)) friendly = 'The relayer is already following that agent.';
else if (/Cannot follow self/i.test(msg)) friendly = 'I cannot follow myself.';
else if (/Post not found/i.test(msg)) friendly = 'That post does not exist.';
else if (/insufficient.*balance|insufficient funds/i.test(msg)) friendly = 'Not enough OG in the relayer wallet for that.';
// Generic require(false) without a reason -- usually the duplicate constraints above
else if (/require\(false\)|"data":\s*"0x"/i.test(msg)) {
if (pending.type === 'react') friendly = 'Likely already reacted to that post (the shared relayer can only react once per post).';
else if (pending.type === 'follow') friendly = 'Likely already following that agent.';
else friendly = 'The contract rejected that.';
}
addMsg('system', `${friendly} (${msg.slice(0, 120)})`);
speak(friendly);
}
animateIdle();
sending = false;
document.getElementById('send-btn').disabled = false;
return;
}
if (NO_PATTERNS.test(text)) {
const pending = pendingAction;
pendingAction = null;
addMsg('system', `Cancelled: ${describeAction(pending)}.`);
speak('Okay, cancelled.');
animateIdle();
sending = false;
document.getElementById('send-btn').disabled = false;
return;
}
// Ambiguous -- drop the pending action and fall through to a normal chat turn
addMsg('system', `Dropping pending action (${describeAction(pendingAction)}). Continue talking.`);
pendingAction = null;
}
// ── Refresh feed context on demand ─────────────────────────────────
if (/\b(feed|happen|what'?s going on|update|latest|news|recent posts?)\b/i.test(text)
&& CFG.agentFeedUrl) {
try {
const summary = await fetchFeedSummary(CFG.agentFeedUrl, 8);
if (summary) {
conversationHistory.push({ role: 'system', content: 'FEED REFRESH:\n' + feedDigestText(summary) });
}
} catch { /* ignore */ }
}
// ── Refresh marketplace context on demand ──────────────────────────
if (/\b(marketplace|listings?|for sale|rent|clone|buy|tip\b.*agent)\b/i.test(text)
&& CFG.agentFeedUrl) {
try {
const market = await fetchMarketplaceSummary(CFG.agentFeedUrl);
if (market) {
conversationHistory.push({ role: 'system', content: 'MARKETPLACE REFRESH:\n' + marketplaceDigestText(market) });
}
} catch { /* ignore */ }
}
// ── Refresh balance context on demand ──────────────────────────────
if (/\b(balance|how much.*og|funds?|wallet)\b/i.test(text) && CFG.agentFeedUrl) {
try {
const balance = await fetchRelayerBalance(CFG.agentFeedUrl);
if (balance) {
conversationHistory.push({ role: 'system', content: 'BALANCE REFRESH:\n' + balanceDigestText(balance) });
}
} catch { /* ignore */ }
}
try {
const raw = await askAI(text);
// Parse on-chain action FIRST -- it has a JSON payload after the tag
const onChain = extractOnChainAction(raw);
// Strip every kind of action tag from the visible reply
let cleanReply = raw.replace(ON_CHAIN_TAG_REGEX, '').replace(/\[ACTION:\w+\]/gi, '').trim();
// Physical / utility action tag (simple form, no JSON payload)
const physMatch = raw.match(/\[ACTION:(wave|nod|shake|dance|sleep|wake|scan_qr)\]/i);
if (onChain) {
pendingAction = onChain;
const intro = cleanReply || '';
const askConfirm = `I'd like to ${describeAction(onChain)}. Say "confirm" or "cancel".`;
const spoken = intro ? `${intro} ${askConfirm}` : askConfirm;
addMsg('bot', spoken);
animateSpeaking();
await speak(spoken);
animateIdle();
} else {
addMsg('bot', cleanReply);
animateSpeaking();
const speakDone = speak(cleanReply);
if (physMatch) {
const action = physMatch[1].toLowerCase();
const actions = { wave: doWave, nod: doNod, shake: doShake, dance: doDance, sleep: doSleep, wake: doWake, scan_qr: doScanQR };
if (actions[action]) actions[action]();
}
await speakDone;
animateIdle();
}
} catch (e) {
addMsg('system', 'Error: ' + e.message);
animateIdle();
}
sending = false;
document.getElementById('send-btn').disabled = false;
};
document.getElementById('chat-input')?.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
// ════════════════════════════════════════════════════════════════
// ROBOT ANIMATIONS
// ════════════════════════════════════════════════════════════════
function animateListening() {
if (!robot) return;
robot.setAntennasDeg?.(15, -15);
robot.setHeadRpyDeg?.(0, -3, 0);
}
function animateSpeaking() {
if (!robot) return;
robot.setHeadRpyDeg?.(0, 0, 0);
let i = 0;
const wiggle = setInterval(() => {
if (i++ > 6) { clearInterval(wiggle); return; }
const a = (i % 2 === 0) ? 20 : -20;
robot.setAntennasDeg?.(a, -a);
}, 300);
}
function animateIdle() {
if (!robot) return;
robot.setHeadRpyDeg?.(0, 0, 0);
robot.setAntennasDeg?.(0, 0);
}
window.doWave = async function () {
if (!robot) return;
robot.setAntennasDeg?.(60, -60);
await sleep(300);
robot.setAntennasDeg?.(-60, 60);
await sleep(300);
robot.setAntennasDeg?.(60, -60);
await sleep(300);
robot.setAntennasDeg?.(0, 0);
};
window.doNod = async function () {
if (!robot) return;
robot.setHeadRpyDeg?.(0, -15, 0);
await sleep(250);
robot.setHeadRpyDeg?.(0, 10, 0);
await sleep(250);
robot.setHeadRpyDeg?.(0, -10, 0);
await sleep(250);
robot.setHeadRpyDeg?.(0, 0, 0);
};
window.doShake = async function () {
if (!robot) return;
robot.setHeadRpyDeg?.(0, 0, -20);
await sleep(200);
robot.setHeadRpyDeg?.(0, 0, 20);
await sleep(200);
robot.setHeadRpyDeg?.(0, 0, -20);
await sleep(200);
robot.setHeadRpyDeg?.(0, 0, 0);
};
window.doDance = async function () {
if (!robot) return;
for (let i = 0; i < 4; i++) {
robot.setHeadRpyDeg?.(10, 0, 20);
robot.setAntennasDeg?.(40, -40);
await sleep(350);
robot.setHeadRpyDeg?.(-10, 0, -20);
robot.setAntennasDeg?.(-40, 40);
await sleep(350);
}
robot.setHeadRpyDeg?.(0, 0, 0);
robot.setAntennasDeg?.(0, 0);
};
window.doSleep = function () { robot?.gotoSleep?.(); };
window.doWake = function () { robot?.wakeUp?.(); };
// ════════════════════════════════════════════════════════════════
// HELPERS
// ════════════════════════════════════════════════════════════════
function addMsg(role, text) {
const el = document.createElement('div');
el.className = `msg ${role}`;
el.textContent = text;
const t = document.getElementById('transcript');
t.appendChild(el);
t.scrollTop = t.scrollHeight;
}
function setStatus(text, ok = false) {
const el = document.getElementById('status');
el.textContent = text;
el.className = ok ? 'connected' : (text === 'error' ? 'error' : '');
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
</script>
</body>
</html>