Spaces:
Running
Running
| <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 <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> | |
| <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> | |