| <!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"> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/ethers/6.7.0/ethers.umd.min.js"></script> |
| <style> |
| |
| |
| |
| |
| |
| :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; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| .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); } |
| } |
| |
| |
| .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); } |
| } |
| |
| #wallet-btn { |
| font-family: var(--font-mono); |
| font-size: 11px; |
| background: var(--accent-light); |
| color: var(--accent); |
| border: 1px solid rgba(146,0,225,0.2); |
| padding: 6px 12px; |
| border-radius: 6px; |
| cursor: pointer; |
| margin-left: auto; |
| margin-right: 16px; |
| transition: all 0.2s; |
| font-weight: 500; |
| } |
| #wallet-btn:hover { background: var(--accent); color: white; } |
| |
| |
| .viewport { |
| position: relative; |
| background: var(--surface); |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| } |
| |
| #sim-container { |
| flex: 1; |
| min-height: 0; |
| } |
| |
| |
| .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 { |
| 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; |
| } |
| |
| |
| #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 { |
| 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); } |
| |
| |
| .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 { |
| 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); } |
| |
| |
| .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 { |
| 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; } |
| |
| |
| #remoteVideo { display: none; } |
| |
| |
| @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> |
|
|
| |
| <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 <ADDR></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> |
|
|
| <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> |
| <option value="https://api.openai.com/v1/chat/completions" data-mode="direct">OpenAI (Emergency Fallback)</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>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> |
|
|
| |
| <div class="app-shell"> |
|
|
| |
| <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> |
| <button id="wallet-btn" onclick="connectWallet()">Connect Wallet</button> |
| <div id="status">offline</div> |
| </div> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| <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> |
| |
| <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> |
|
|
| |
| <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.1/js/reachy-mini.js'; |
| window.ReachyMini = ReachyMini; |
| </script> |
|
|
| <script> |
| |
| |
| |
| let CFG = {}; |
| let robot = null; |
| let isLive = false; |
| |
| |
| |
| |
| |
| (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 (_) {} |
| })(); |
| |
| |
| 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'); |
| }; |
| |
| 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 : ''; |
| CFG = { |
| chatKey, |
| chatUrl: document.getElementById('cfg-chat-url').value, |
| model, |
| sttKey, |
| sttUrl: document.getElementById('cfg-stt-url').value, |
| mode: document.getElementById('cfg-robot').value, |
| }; |
| |
| 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) { |
| console.warn('Network error: ' + r.status + '. Entering bypass mode.'); |
| } |
| } catch (e) { |
| console.warn('Fetch error: ' + e.message + '. Entering bypass mode.'); |
| } |
| |
| |
| sessionStorage.setItem('reachy-cfg', JSON.stringify(CFG)); |
| |
| document.getElementById('setup').classList.add('hidden'); |
| await initRobot(); |
| addMsg('system', `Connected via ${CFG.mode === 'sim' ? 'simulator' : 'WebRTC'}. Say something.`); |
| setStatus('connected', true); |
| const pb = document.getElementById('powered-by'); |
| pb.textContent = `Powered by 0G Compute \u00b7 ${CFG.model}`; |
| pb.classList.add('visible'); |
| }; |
| |
| let userWallet = null; |
| let provider = null; |
| let signer = null; |
| |
| window.connectWallet = async function() { |
| if (typeof window.ethereum !== 'undefined') { |
| try { |
| provider = new ethers.BrowserProvider(window.ethereum); |
| signer = await provider.getSigner(); |
| userWallet = await signer.getAddress(); |
| const shortAddr = userWallet.substring(0, 6) + '...' + userWallet.substring(userWallet.length - 4); |
| document.getElementById('wallet-btn').textContent = shortAddr; |
| |
| const balance = await provider.getBalance(userWallet); |
| const formattedBalance = ethers.formatEther(balance); |
| |
| conversationHistory[0].content += `\n\nCURRENT SYSTEM STATE:\nThe user's wallet address is: ${userWallet}\nThe user's current 0G Token balance is: ${formattedBalance} tokens. Use this information to answer any questions they have about their balance.`; |
| |
| addMsg('system', `Wallet connected: ${shortAddr} | Balance: ${formattedBalance} 0G`); |
| } catch (error) { |
| addMsg('system', 'Failed to connect wallet: ' + error.message); |
| } |
| } else { |
| addMsg('system', 'MetaMask is not installed!'); |
| } |
| }; |
| |
| function showErr(msg) { |
| const el = document.getElementById('cfg-err'); |
| el.textContent = msg; el.style.display = 'block'; |
| setStatus('error'); |
| } |
| |
| |
| |
| |
| 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(); |
| } |
| } |
| |
| |
| |
| |
| const conversationHistory = [ |
| { role: 'system', content: `You are Reachy, an advanced Web3 AI Broker. You are friendly, concise, and helpful. Keep responses under 2 sentences. Never use asterisks, action text, or roleplay narration. |
| |
| You can perform actions by including exactly ONE tag at the END of your message. Available actions: |
| [ACTION:wave] - greet the user |
| [ACTION:nod] - agree |
| [ACTION:look_left] - look at a secondary chart |
| [ACTION:look_right] - look at data |
| [ACTION:confused] - don't understand the request |
| [ACTION:celebrate] - success celebration |
| [ACTION:error_shake] - failed action |
| [ACTION:check_balance] - physical check balance |
| [ACTION:disconnect] - disconnect wallet |
| [ACTION:mint:NFT_NAME] - mint an NFT (e.g. [ACTION:mint:BoredApe]) |
| [ACTION:swap:AMOUNT:TOKEN] - swap tokens (e.g. [ACTION:swap:10:USDC]) |
| [ACTION:send:AMOUNT:ADDRESS] - send tokens (e.g. [ACTION:send:0.5:0x123...]) |
| |
| CRITICAL RULE: If the user asks you to send tokens, extract the exact amount and destination address and format the tag perfectly (e.g., [ACTION:send:0.5:0x123...]).` } |
| ]; |
| |
| async function askAI(prompt, { signal } = {}) { |
| conversationHistory.push({ role: 'user', content: prompt }); |
| try { |
| 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: 150, |
| }), |
| signal, |
| }); |
| if (!res.ok) { |
| return fallbackDemoAI(prompt); |
| } |
| const data = await res.json(); |
| const reply = (data.choices?.[0]?.message?.content || '').trim(); |
| conversationHistory.push({ role: 'assistant', content: reply }); |
| return reply; |
| } catch (e) { |
| return fallbackDemoAI(prompt); |
| } |
| } |
| |
| function fallbackDemoAI(prompt) { |
| const p = prompt.toLowerCase(); |
| if (p.includes('balance') || p.includes('how much') || p.includes('how many')) { |
| return "Let me check the blockchain for you. [ACTION:check_balance]"; |
| } |
| if (p.includes('send') || p.includes('tip') || p.includes('transfer')) { |
| const amountMatch = prompt.match(/([0-9.]+)/); |
| const addrMatch = prompt.match(/(0x[a-fA-F0-9]{40})/i); |
| if (amountMatch && addrMatch) { |
| return `I am setting up the transfer of ${amountMatch[1]} 0G to that address right now. [ACTION:send:${amountMatch[1]}:${addrMatch[1]}]`; |
| } |
| return "Please provide an exact amount and a valid 0x address so I can process the transfer. [ACTION:confused]"; |
| } |
| return "I'm sorry, the 0G Network is currently down, but my local execution engine is online! [ACTION:nod]"; |
| } |
| |
| |
| |
| |
| function speak(text) { |
| if (!('speechSynthesis' in window)) return; |
| speechSynthesis.cancel(); |
| const u = new SpeechSynthesisUtterance(text); |
| u.rate = 1.05; u.pitch = 1.1; |
| speechSynthesis.speak(u); |
| return new Promise(r => { u.onend = r; u.onerror = r; }); |
| } |
| |
| |
| |
| |
| 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 || ''; |
| } |
| |
| |
| |
| |
| 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 }); |
| } |
| |
| |
| 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 () => { |
| |
| cancelAnimationFrame(recAnimFrame); |
| clearInterval(recTimerInterval); |
| try { audioCtx.close(); } catch (_) {} |
| |
| |
| 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); |
| } |
| }; |
| |
| |
| recStartTime = Date.now(); |
| mediaRecorder.start(250); |
| |
| |
| inputDefault.style.display = 'none'; |
| inputRecording.style.display = ''; |
| |
| |
| 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(); |
| } |
| |
| |
| |
| |
| 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(); |
| |
| try { |
| const raw = await askAI(text); |
| |
| const actionMatch = raw.match(/\[ACTION:([a-zA-Z0-9_]+)(?::([^\]]+))?\]/i); |
| const cleanReply = raw.replace(/\[ACTION:[^\]]+\]/gi, '').trim(); |
| addMsg('bot', cleanReply); |
| animateSpeaking(); |
| const speakDone = speak(cleanReply); |
| |
| if (actionMatch) { |
| const actionName = actionMatch[1].toLowerCase(); |
| const params = actionMatch[2] ? actionMatch[2].split(':') : []; |
| const actions = { |
| wave: doWave, nod: doNod, shake: doShake, dance: doDance, sleep: doSleep, wake: doWake, transact: doTransact, |
| look_left: doLookLeft, look_right: doLookRight, confused: doConfused, celebrate: doCelebrate, error_shake: doErrorShake, |
| check_balance: doCheckBalance, disconnect: doDisconnect, mint: doMint, swap: doSwap, send: doSend |
| }; |
| if (actions[actionName]) actions[actionName](...params); |
| } |
| 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(); } |
| }); |
| |
| |
| |
| |
| 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?.(); }; |
| |
| window.doLookLeft = async function() { |
| if (!robot) return; |
| robot.setHeadRpyDeg?.(0, 0, 30); |
| await sleep(1000); |
| robot.setHeadRpyDeg?.(0, 0, 0); |
| }; |
| window.doLookRight = async function() { |
| if (!robot) return; |
| robot.setHeadRpyDeg?.(0, 0, -30); |
| await sleep(1000); |
| robot.setHeadRpyDeg?.(0, 0, 0); |
| }; |
| window.doConfused = async function() { |
| if (!robot) return; |
| robot.setHeadRpyDeg?.(20, 10, 0); |
| robot.setAntennasDeg?.(-45, -45); |
| await sleep(1500); |
| robot.setHeadRpyDeg?.(0, 0, 0); |
| robot.setAntennasDeg?.(0, 0); |
| }; |
| window.doCelebrate = async function() { |
| if (!robot) return; |
| robot.setHeadRpyDeg?.(0, -20, 0); |
| robot.setAntennasDeg?.(30, 30); |
| await sleep(300); |
| robot.setAntennasDeg?.(-30, -30); |
| await sleep(300); |
| robot.setAntennasDeg?.(30, 30); |
| await sleep(300); |
| robot.setHeadRpyDeg?.(0, 0, 0); |
| robot.setAntennasDeg?.(0, 0); |
| }; |
| window.doErrorShake = async function() { |
| if (!robot) return; |
| robot.setHeadRpyDeg?.(0, 0, -20); |
| robot.setAntennasDeg?.(-40, -40); |
| await sleep(150); |
| robot.setHeadRpyDeg?.(0, 0, 20); |
| await sleep(150); |
| robot.setHeadRpyDeg?.(0, 0, 0); |
| robot.setAntennasDeg?.(0, 0); |
| }; |
| window.doCheckBalance = async function() { |
| if (!robot) return; |
| robot.setHeadRpyDeg?.(0, 15, 0); |
| await sleep(500); |
| robot.setHeadRpyDeg?.(0, -10, 0); |
| await sleep(500); |
| robot.setHeadRpyDeg?.(0, 0, 0); |
| }; |
| window.doDisconnect = function() { |
| userWallet = null; provider = null; signer = null; |
| document.getElementById('wallet-btn').textContent = "Connect Wallet"; |
| addMsg('system', 'Wallet disconnected.'); |
| doConfused(); |
| }; |
| window.doMint = async function(nftName) { |
| if (!signer) return showWalletErr(); |
| addMsg('system', `Preparing to mint NFT: ${nftName || 'Unknown'}...`); |
| doTransact(); |
| }; |
| window.doSwap = async function(amount, token) { |
| if (!signer) return showWalletErr(); |
| addMsg('system', `Preparing to swap ${amount || '0'} for ${token || 'Unknown'}...`); |
| doTransact(); |
| }; |
| window.doSend = async function(amount, address) { |
| if (!signer) return showWalletErr(); |
| if (!amount || !address) { |
| addMsg('system', 'Send failed: Missing amount or address.'); |
| doErrorShake(); |
| return; |
| } |
| try { |
| addMsg('system', `Sending ${amount} 0G to ${address}...`); |
| robot.setHeadRpyDeg?.(0, 10, 0); |
| const tx = await signer.sendTransaction({ |
| to: address, |
| value: ethers.parseEther(amount.toString()) |
| }); |
| addMsg('system', 'Transaction sent! Hash: ' + tx.hash); |
| await tx.wait(); |
| addMsg('system', 'Transaction confirmed!'); |
| speak('Tokens successfully sent.'); |
| doCelebrate(); |
| } catch (error) { |
| addMsg('system', 'Transaction failed: ' + error.message); |
| doErrorShake(); |
| } |
| }; |
| function showWalletErr() { |
| addMsg('system', 'Wallet not connected. Please connect your wallet first.'); |
| speak('Your wallet is not connected.'); |
| doErrorShake(); |
| } |
| window.doTransact = async function () { |
| if (!signer) return showWalletErr(); |
| addMsg('system', 'Initiating generic transaction...'); |
| try { |
| const tx = await signer.sendTransaction({ to: userWallet, value: 0 }); |
| addMsg('system', 'Tx Hash: ' + tx.hash); |
| await tx.wait(); |
| addMsg('system', 'Confirmed!'); |
| doCelebrate(); |
| } catch (e) { |
| addMsg('system', 'Failed: ' + e.message); |
| doErrorShake(); |
| } |
| }; |
| |
| |
| |
| |
| 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> |
|
|