XixDegen commited on
Commit
da2c862
·
verified ·
1 Parent(s): 03cc930

Upload 2 files

Browse files
Files changed (2) hide show
  1. index.html +1495 -18
  2. sim.js +418 -0
index.html CHANGED
@@ -1,19 +1,1496 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Reachy Mini + 0G</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <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">
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/ethers/6.7.0/ethers.umd.min.js"></script>
11
+ <style>
12
+ /* ═══════════════════════════════════════════════════════════
13
+ REACHY MINI + 0G — Light + 0G Purple
14
+ Off-white surfaces / purple accent
15
+ ═══════════════════════════════════════════════════════════ */
16
+
17
+ :root {
18
+ --surface: #F8F7F4;
19
+ --ink: #FFFFFF;
20
+ --panel: #FFFFFF;
21
+ --panel-elevated: #F3F2EF;
22
+ --accent: #9200E1;
23
+ --accent-dim: rgba(146, 0, 225, 0.08);
24
+ --accent-glow: rgba(146, 0, 225, 0.2);
25
+ --accent-light: #F3E8FF;
26
+ --text-primary: #1A1A1F;
27
+ --text-secondary: #6B6B76;
28
+ --text-muted: #A0A0AB;
29
+ --border: rgba(0, 0, 0, 0.07);
30
+ --border-light: rgba(0, 0, 0, 0.05);
31
+ --user-bubble: #9200E1;
32
+ --bot-bubble: #F3F2EF;
33
+ --danger: #F43F5E;
34
+ --radius-sm: 8px;
35
+ --radius-md: 14px;
36
+ --radius-lg: 20px;
37
+ --radius-xl: 28px;
38
+ --font-body: 'DM Sans', sans-serif;
39
+ --font-mono: 'JetBrains Mono', monospace;
40
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
41
+ --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
42
+ }
43
+
44
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
45
+
46
+ html { height: 100%; }
47
+
48
+ body {
49
+ font-family: var(--font-body);
50
+ background: var(--ink);
51
+ color: var(--text-primary);
52
+ height: 100vh;
53
+ overflow: hidden;
54
+ -webkit-font-smoothing: antialiased;
55
+ -moz-osx-font-smoothing: grayscale;
56
+ }
57
+
58
+ /* ── Subtle grain ── */
59
+ body::after {
60
+ content: '';
61
+ position: fixed;
62
+ inset: 0;
63
+ 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");
64
+ background-repeat: repeat;
65
+ background-size: 256px;
66
+ pointer-events: none;
67
+ z-index: 9999;
68
+ opacity: 0.35;
69
+ }
70
+
71
+ /* ═══ LAYOUT ═══ */
72
+ .app-shell {
73
+ display: grid;
74
+ grid-template-columns: 1fr 380px;
75
+ grid-template-rows: auto 1fr;
76
+ height: 100vh;
77
+ opacity: 0;
78
+ animation: shellIn 0.8s var(--ease-out) 0.1s forwards;
79
+ }
80
+
81
+ @keyframes shellIn {
82
+ from { opacity: 0; transform: translateY(6px); }
83
+ to { opacity: 1; transform: translateY(0); }
84
+ }
85
+
86
+ /* ═══ TOP BAR ═══ */
87
+ .topbar {
88
+ grid-column: 1 / -1;
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: space-between;
92
+ padding: 14px 24px;
93
+ background: var(--panel);
94
+ border-bottom: 1px solid var(--border);
95
+ z-index: 10;
96
+ }
97
+
98
+ .topbar-brand {
99
+ display: flex;
100
+ align-items: center;
101
+ gap: 10px;
102
+ }
103
+
104
+ .topbar-brand svg {
105
+ width: 22px;
106
+ height: 22px;
107
+ color: var(--accent);
108
+ }
109
+
110
+ .topbar-brand h1 {
111
+ font-size: 14px;
112
+ font-weight: 500;
113
+ letter-spacing: -0.01em;
114
+ color: var(--text-primary);
115
+ }
116
+
117
+ .topbar-brand span {
118
+ font-size: 11px;
119
+ font-family: var(--font-mono);
120
+ color: var(--accent);
121
+ padding: 3px 9px;
122
+ background: var(--accent-light);
123
+ border-radius: 5px;
124
+ letter-spacing: 0.04em;
125
+ font-weight: 500;
126
+ }
127
+
128
+ #status {
129
+ display: flex;
130
+ align-items: center;
131
+ gap: 6px;
132
+ font-size: 12px;
133
+ font-weight: 500;
134
+ color: var(--text-muted);
135
+ letter-spacing: 0.02em;
136
+ text-transform: uppercase;
137
+ }
138
+
139
+ #status::before {
140
+ content: '';
141
+ width: 7px;
142
+ height: 7px;
143
+ border-radius: 50%;
144
+ background: var(--text-muted);
145
+ transition: all 0.4s var(--ease-out);
146
+ }
147
+
148
+ #status.connected { color: #16a34a; }
149
+ #status.connected::before {
150
+ background: #16a34a;
151
+ box-shadow: 0 0 8px rgba(22,163,74,0.3);
152
+ animation: pulse 2.4s ease-in-out infinite;
153
+ }
154
+
155
+ #status.error { color: var(--danger); }
156
+ #status.error::before { background: var(--danger); box-shadow: 0 0 8px rgba(244,63,94,0.3); }
157
+
158
+ @keyframes pulse {
159
+ 0%, 100% { opacity: 1; transform: scale(1); }
160
+ 50% { opacity: 0.5; transform: scale(0.85); }
161
+ }
162
+
163
+ #wallet-btn {
164
+ font-family: var(--font-mono);
165
+ font-size: 11px;
166
+ background: var(--accent-light);
167
+ color: var(--accent);
168
+ border: 1px solid rgba(146,0,225,0.2);
169
+ padding: 6px 12px;
170
+ border-radius: 6px;
171
+ cursor: pointer;
172
+ margin-left: auto;
173
+ margin-right: 16px;
174
+ transition: all 0.2s;
175
+ font-weight: 500;
176
+ }
177
+ #wallet-btn:hover { background: var(--accent); color: white; }
178
+
179
+ /* ═══ VIEWPORT (3D sim / video) ═══ */
180
+ .viewport {
181
+ position: relative;
182
+ background: var(--surface);
183
+ display: flex;
184
+ flex-direction: column;
185
+ overflow: hidden;
186
+ }
187
+
188
+ #sim-container {
189
+ flex: 1;
190
+ min-height: 0;
191
+ }
192
+
193
+ /* ── Motion controls bar ── */
194
+ .motion-bar {
195
+ display: flex;
196
+ align-items: center;
197
+ gap: 6px;
198
+ padding: 12px 20px;
199
+ background: var(--panel);
200
+ border-top: 1px solid var(--border);
201
+ }
202
+
203
+ .motion-bar-label {
204
+ font-size: 10px;
205
+ font-family: var(--font-mono);
206
+ font-weight: 500;
207
+ text-transform: uppercase;
208
+ letter-spacing: 0.08em;
209
+ color: var(--text-muted);
210
+ margin-right: 8px;
211
+ flex-shrink: 0;
212
+ }
213
+
214
+ .motion-btn {
215
+ display: inline-flex;
216
+ align-items: center;
217
+ gap: 5px;
218
+ padding: 7px 14px;
219
+ font-family: var(--font-body);
220
+ font-size: 12px;
221
+ font-weight: 500;
222
+ color: var(--text-secondary);
223
+ background: var(--surface);
224
+ border: 1px solid var(--border);
225
+ border-radius: 100px;
226
+ cursor: pointer;
227
+ transition: all 0.2s var(--ease-out);
228
+ white-space: nowrap;
229
+ }
230
+
231
+ .motion-btn:hover {
232
+ background: var(--accent-light);
233
+ border-color: rgba(146, 0, 225, 0.25);
234
+ color: var(--accent);
235
+ transform: translateY(-1px);
236
+ box-shadow: 0 2px 12px rgba(146, 0, 225, 0.1);
237
+ }
238
+
239
+ .motion-btn:active {
240
+ transform: translateY(0);
241
+ background: rgba(146, 0, 225, 0.12);
242
+ }
243
+
244
+ .motion-btn svg { width: 13px; height: 13px; opacity: 0.6; }
245
+
246
+ /* ═══ CONVERSATION PANEL ═══ */
247
+ .conversation {
248
+ display: flex;
249
+ flex-direction: column;
250
+ background: var(--panel);
251
+ border-left: 1px solid var(--border);
252
+ overflow: hidden;
253
+ }
254
+
255
+ .conv-header {
256
+ display: flex;
257
+ align-items: center;
258
+ gap: 8px;
259
+ padding: 16px 20px;
260
+ border-bottom: 1px solid var(--border);
261
+ }
262
+
263
+ .conv-header-dot {
264
+ width: 8px;
265
+ height: 8px;
266
+ border-radius: 50%;
267
+ background: var(--accent);
268
+ opacity: 0.7;
269
+ }
270
+
271
+ .conv-header h2 {
272
+ font-size: 13px;
273
+ font-weight: 500;
274
+ color: var(--text-secondary);
275
+ letter-spacing: 0.01em;
276
+ }
277
+
278
+ /* ── Messages ── */
279
+ #transcript {
280
+ flex: 1;
281
+ overflow-y: auto;
282
+ padding: 20px;
283
+ display: flex;
284
+ flex-direction: column;
285
+ gap: 10px;
286
+ scrollbar-width: thin;
287
+ scrollbar-color: rgba(0, 0, 0, 0.08) transparent;
288
+ }
289
+
290
+ #transcript::-webkit-scrollbar { width: 5px; }
291
+ #transcript::-webkit-scrollbar-track { background: transparent; }
292
+ #transcript::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.08); border-radius: 10px; }
293
+
294
+ .msg {
295
+ max-width: 88%;
296
+ padding: 10px 15px;
297
+ border-radius: var(--radius-md);
298
+ font-size: 13.5px;
299
+ line-height: 1.55;
300
+ word-break: break-word;
301
+ animation: msgIn 0.35s var(--ease-spring) both;
302
+ }
303
+
304
+ @keyframes msgIn {
305
+ from { opacity: 0; transform: translateY(8px) scale(0.97); }
306
+ to { opacity: 1; transform: translateY(0) scale(1); }
307
+ }
308
+
309
+ .msg.user {
310
+ align-self: flex-end;
311
+ background: var(--user-bubble);
312
+ color: #FFFFFF;
313
+ border-bottom-right-radius: 4px;
314
+ }
315
+
316
+ .msg.bot {
317
+ align-self: flex-start;
318
+ background: var(--bot-bubble);
319
+ color: var(--text-primary);
320
+ border-bottom-left-radius: 4px;
321
+ }
322
+
323
+ .msg.system {
324
+ align-self: center;
325
+ background: transparent;
326
+ color: var(--text-muted);
327
+ font-size: 11.5px;
328
+ font-style: italic;
329
+ padding: 4px 0;
330
+ }
331
+
332
+ /* ── Input area ── */
333
+ .input-area {
334
+ padding: 16px 20px;
335
+ border-top: 1px solid var(--border);
336
+ background: var(--panel-elevated);
337
+ }
338
+
339
+ .input-row {
340
+ display: flex;
341
+ align-items: center;
342
+ gap: 8px;
343
+ background: var(--surface);
344
+ border: 1px solid var(--border);
345
+ border-radius: var(--radius-lg);
346
+ padding: 4px 4px 4px 16px;
347
+ transition: border-color 0.25s var(--ease-out), box-shadow 0.25s var(--ease-out);
348
+ }
349
+
350
+ .input-row:focus-within {
351
+ border-color: rgba(146, 0, 225, 0.35);
352
+ box-shadow: 0 0 0 3px var(--accent-dim);
353
+ }
354
+
355
+ #chat-input {
356
+ flex: 1;
357
+ background: transparent;
358
+ border: none;
359
+ outline: none;
360
+ color: var(--text-primary);
361
+ font-family: var(--font-body);
362
+ font-size: 14px;
363
+ padding: 8px 0;
364
+ min-width: 0;
365
+ }
366
+
367
+ #chat-input::placeholder { color: var(--text-muted); }
368
+
369
+ #mic-btn {
370
+ display: flex;
371
+ align-items: center;
372
+ justify-content: center;
373
+ width: 36px;
374
+ height: 36px;
375
+ border-radius: 50%;
376
+ border: none;
377
+ background: var(--panel-elevated);
378
+ color: var(--text-secondary);
379
+ cursor: pointer;
380
+ transition: all 0.2s var(--ease-out);
381
+ flex-shrink: 0;
382
+ }
383
+
384
+ #mic-btn:hover { background: rgba(0, 0, 0, 0.08); color: var(--text-primary); }
385
+
386
+ /* ── Recording row ── */
387
+ .rec-row {
388
+ display: flex;
389
+ align-items: center;
390
+ gap: 12px;
391
+ background: var(--danger);
392
+ border: 1px solid var(--danger);
393
+ border-radius: var(--radius-lg);
394
+ padding: 6px 6px 6px 16px;
395
+ animation: recRowIn 0.3s var(--ease-spring);
396
+ }
397
+
398
+ @keyframes recRowIn {
399
+ from { opacity: 0; transform: scale(0.97); }
400
+ to { opacity: 1; transform: scale(1); }
401
+ }
402
+
403
+ .rec-info {
404
+ display: flex;
405
+ align-items: center;
406
+ gap: 8px;
407
+ flex-shrink: 0;
408
+ }
409
+
410
+ .rec-dot {
411
+ width: 8px;
412
+ height: 8px;
413
+ border-radius: 50%;
414
+ background: white;
415
+ animation: recBlink 1s ease-in-out infinite;
416
+ }
417
+
418
+ @keyframes recBlink {
419
+ 0%, 100% { opacity: 1; }
420
+ 50% { opacity: 0.3; }
421
+ }
422
+
423
+ .rec-timer {
424
+ font-family: var(--font-mono);
425
+ font-size: 13px;
426
+ font-weight: 500;
427
+ color: white;
428
+ min-width: 32px;
429
+ }
430
+
431
+ #rec-waveform {
432
+ flex: 1;
433
+ height: 32px;
434
+ min-width: 0;
435
+ opacity: 0.7;
436
+ }
437
+
438
+ .rec-stop {
439
+ display: flex;
440
+ align-items: center;
441
+ justify-content: center;
442
+ width: 38px;
443
+ height: 38px;
444
+ border-radius: 50%;
445
+ border: none;
446
+ background: white;
447
+ color: var(--danger);
448
+ cursor: pointer;
449
+ transition: all 0.2s var(--ease-out);
450
+ flex-shrink: 0;
451
+ }
452
+
453
+ .rec-stop:hover { transform: scale(1.08); }
454
+ .rec-stop:active { transform: scale(0.95); }
455
+
456
+ #send-btn {
457
+ display: flex;
458
+ align-items: center;
459
+ justify-content: center;
460
+ width: 38px;
461
+ height: 38px;
462
+ border-radius: 50%;
463
+ border: none;
464
+ background: var(--accent);
465
+ color: var(--ink);
466
+ cursor: pointer;
467
+ transition: all 0.2s var(--ease-out);
468
+ flex-shrink: 0;
469
+ }
470
+
471
+ #send-btn:hover { transform: scale(1.06); box-shadow: 0 0 14px var(--accent-glow); }
472
+ #send-btn:active { transform: scale(0.96); }
473
+ #send-btn:disabled { opacity: 0.3; cursor: default; transform: none; box-shadow: none; }
474
+
475
+ #send-btn svg { width: 16px; height: 16px; }
476
+
477
+ /* ═══ SETUP OVERLAY ═══ */
478
+ #setup {
479
+ position: fixed;
480
+ inset: 0;
481
+ z-index: 1000;
482
+ display: flex;
483
+ align-items: center;
484
+ justify-content: center;
485
+ background: rgba(255, 255, 255, 0.88);
486
+ backdrop-filter: blur(40px) saturate(1.4);
487
+ -webkit-backdrop-filter: blur(40px) saturate(1.4);
488
+ animation: overlayIn 0.5s var(--ease-out);
489
+ }
490
+
491
+ #setup.hidden {
492
+ display: none;
493
+ }
494
+
495
+ @keyframes overlayIn {
496
+ from { opacity: 0; }
497
+ to { opacity: 1; }
498
+ }
499
+
500
+ .setup-card {
501
+ width: 90%;
502
+ max-width: 440px;
503
+ background: var(--panel);
504
+ border: 1px solid var(--border);
505
+ border-radius: var(--radius-xl);
506
+ padding: 40px 36px 36px;
507
+ animation: cardIn 0.6s var(--ease-spring) 0.15s both;
508
+ position: relative;
509
+ overflow: hidden;
510
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
511
+ }
512
+
513
+ .setup-card::before {
514
+ content: '';
515
+ position: absolute;
516
+ top: -1px;
517
+ left: 40px;
518
+ right: 40px;
519
+ height: 1px;
520
+ background: linear-gradient(90deg, transparent, var(--accent), transparent);
521
+ opacity: 0.5;
522
+ }
523
+
524
+ @keyframes cardIn {
525
+ from { opacity: 0; transform: translateY(20px) scale(0.96); }
526
+ to { opacity: 1; transform: translateY(0) scale(1); }
527
+ }
528
+
529
+ .setup-icon {
530
+ display: flex;
531
+ align-items: center;
532
+ justify-content: center;
533
+ width: 48px;
534
+ height: 48px;
535
+ border-radius: 14px;
536
+ background: var(--accent-dim);
537
+ margin-bottom: 20px;
538
+ }
539
+
540
+ .setup-icon svg { width: 24px; height: 24px; color: var(--accent); }
541
+
542
+ .setup-card h2 {
543
+ font-size: 20px;
544
+ font-weight: 600;
545
+ letter-spacing: -0.02em;
546
+ margin-bottom: 6px;
547
+ }
548
+
549
+ .setup-card .setup-subtitle {
550
+ font-size: 13px;
551
+ color: var(--text-secondary);
552
+ line-height: 1.5;
553
+ margin-bottom: 28px;
554
+ }
555
+
556
+ .setup-card .setup-subtitle a {
557
+ color: var(--accent);
558
+ text-decoration: none;
559
+ border-bottom: 1px solid transparent;
560
+ transition: border-color 0.2s;
561
+ }
562
+
563
+ .setup-card .setup-subtitle a:hover { border-bottom-color: var(--accent); }
564
+
565
+ .field-group { margin-bottom: 16px; }
566
+
567
+ .field-group label {
568
+ display: block;
569
+ font-size: 11.5px;
570
+ font-weight: 500;
571
+ text-transform: uppercase;
572
+ letter-spacing: 0.06em;
573
+ color: var(--text-muted);
574
+ margin-bottom: 6px;
575
+ }
576
+
577
+ .field-group input,
578
+ .field-group select {
579
+ width: 100%;
580
+ padding: 11px 14px;
581
+ font-family: var(--font-mono);
582
+ font-size: 13px;
583
+ color: var(--text-primary);
584
+ background: var(--surface);
585
+ border: 1px solid var(--border);
586
+ border-radius: var(--radius-sm);
587
+ outline: none;
588
+ transition: border-color 0.25s var(--ease-out), box-shadow 0.25s var(--ease-out);
589
+ -webkit-appearance: none;
590
+ }
591
+
592
+ .field-group input:focus,
593
+ .field-group select:focus {
594
+ border-color: rgba(146, 0, 225, 0.45);
595
+ box-shadow: 0 0 0 3px var(--accent-dim);
596
+ }
597
+
598
+ .field-group input::placeholder { color: var(--text-muted); font-family: var(--font-mono); }
599
+
600
+ .field-group .field-hint {
601
+ font-size: 11px;
602
+ color: var(--text-muted);
603
+ margin-top: 4px;
604
+ line-height: 1.4;
605
+ }
606
+
607
+ .field-group select {
608
+ cursor: pointer;
609
+ 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");
610
+ background-repeat: no-repeat;
611
+ background-position: right 12px center;
612
+ padding-right: 34px;
613
+ }
614
+ .field-group select option { background: var(--panel); color: var(--text-primary); }
615
+
616
+ /* Collapsible advanced fields */
617
+ .advanced-toggle {
618
+ display: flex;
619
+ align-items: center;
620
+ gap: 6px;
621
+ font-size: 11.5px;
622
+ color: var(--text-muted);
623
+ cursor: pointer;
624
+ margin: 16px 0 12px;
625
+ padding: 0;
626
+ border: none;
627
+ background: none;
628
+ font-family: var(--font-body);
629
+ transition: color 0.2s;
630
+ }
631
+
632
+ .advanced-toggle:hover { color: var(--text-secondary); }
633
+ .advanced-toggle svg { width: 12px; height: 12px; transition: transform 0.25s var(--ease-out); }
634
+ .advanced-toggle.open svg { transform: rotate(90deg); }
635
+ .advanced-fields { display: none; }
636
+ .advanced-fields.open { display: block; }
637
+
638
+ .setup-start {
639
+ width: 100%;
640
+ padding: 14px;
641
+ margin-top: 24px;
642
+ font-family: var(--font-body);
643
+ font-size: 14px;
644
+ font-weight: 600;
645
+ color: var(--ink);
646
+ background: var(--accent);
647
+ border: none;
648
+ border-radius: var(--radius-md);
649
+ cursor: pointer;
650
+ transition: all 0.2s var(--ease-out);
651
+ letter-spacing: -0.01em;
652
+ }
653
+
654
+ .setup-start:hover {
655
+ transform: translateY(-1px);
656
+ box-shadow: 0 4px 20px var(--accent-glow);
657
+ }
658
+
659
+ .setup-start:active { transform: translateY(0); }
660
+
661
+ #cfg-err {
662
+ color: var(--danger);
663
+ font-size: 12px;
664
+ margin-top: 10px;
665
+ display: none;
666
+ line-height: 1.4;
667
+ }
668
+
669
+ /* ── Powered-by footer ── */
670
+ .powered-by {
671
+ padding: 8px 20px;
672
+ font-size: 10.5px;
673
+ font-family: var(--font-mono);
674
+ color: var(--text-muted);
675
+ text-align: center;
676
+ letter-spacing: 0.02em;
677
+ display: none;
678
+ }
679
+
680
+ .powered-by.visible { display: block; }
681
+
682
+ /* ═══ HIDDEN ELEMENTS ═══ */
683
+ #remoteVideo { display: none; }
684
+
685
+ /* ═══ RESPONSIVE ═══ */
686
+ @media (max-width: 800px) {
687
+ .app-shell {
688
+ grid-template-columns: 1fr;
689
+ grid-template-rows: auto 240px 1fr;
690
+ }
691
+ .viewport { min-height: 0; }
692
+ .conversation { border-left: none; border-top: 1px solid var(--border); }
693
+ .motion-bar { overflow-x: auto; }
694
+ }
695
+ </style>
696
+ </head>
697
+ <body>
698
+
699
+ <!-- ═══ SETUP OVERLAY ═══ -->
700
+ <div id="setup">
701
+ <div class="setup-card">
702
+ <div class="setup-icon">
703
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
704
+ <path d="M12 2a4 4 0 0 1 4 4v2H8V6a4 4 0 0 1 4-4Z"/>
705
+ <rect x="4" y="8" width="16" height="12" rx="3"/>
706
+ <circle cx="9" cy="14" r="1.5" fill="currentColor"/>
707
+ <circle cx="15" cy="14" r="1.5" fill="currentColor"/>
708
+ </svg>
709
+ </div>
710
+ <h2>Connect to 0G</h2>
711
+ <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>
712
+
713
+ <div class="field-group">
714
+ <label>API Key</label>
715
+ <input id="cfg-chat-key" type="password" placeholder="app-sk-..." spellcheck="false" />
716
+ <div class="field-hint">From <code>0g-compute-cli inference get-secret --provider &lt;ADDR&gt;</code></div>
717
+ </div>
718
+
719
+ <div class="field-group">
720
+ <label>Model</label>
721
+ <select id="cfg-model" onchange="syncEndpoint()">
722
+ <option value="zai-org/GLM-5-FP8" data-endpoint="https://compute-network-1.integratenetwork.work">GLM-5 FP8</option>
723
+ <option value="deepseek/deepseek-chat-v3-0324" data-endpoint="https://compute-network-1.integratenetwork.work">DeepSeek Chat v3</option>
724
+ <option value="qwen3.6-plus" data-endpoint="https://compute-network-1.integratenetwork.work">Qwen 3.6 Plus</option>
725
+ <option value="custom">Custom model...</option>
726
+ </select>
727
+ </div>
728
+
729
+ <div class="field-group">
730
+ <label>Robot</label>
731
+ <select id="cfg-robot">
732
+ <option value="sim">3D Simulator</option>
733
+ <option value="live">Live Robot (WebRTC)</option>
734
+ </select>
735
+ </div>
736
+
737
+ <button class="advanced-toggle" type="button" onclick="this.classList.toggle('open'); document.getElementById('adv-fields').classList.toggle('open');">
738
+ <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>
739
+ Advanced settings
740
+ </button>
741
+
742
+ <div id="adv-fields" class="advanced-fields">
743
+ <div class="field-group" id="custom-model-group" style="display:none;">
744
+ <label>Custom Model ID</label>
745
+ <input id="cfg-custom-model" placeholder="org/model-name" />
746
+ </div>
747
+ <div class="field-group">
748
+ <label>Chat Endpoint</label>
749
+ <select id="cfg-chat-url" onchange="syncMode()">
750
+ <option value="https://compute-network-1.integratenetwork.work/v1/proxy/chat/completions" data-mode="direct">compute-network-1 (GLM, DeepSeek, Qwen)</option>
751
+ <option value="https://compute-network-2.integratenetwork.work/v1/proxy/chat/completions" data-mode="direct">compute-network-2</option>
752
+ <option value="https://compute-network-3.integratenetwork.work/v1/proxy/chat/completions" data-mode="direct">compute-network-3</option>
753
+ </select>
754
+ <div class="field-hint">Check your provider's network on <a href="https://pc.0g.ai" target="_blank">pc.0g.ai</a></div>
755
+ </div>
756
+ <div class="field-group">
757
+ <label>Voice Input</label>
758
+ <select id="cfg-stt-provider" onchange="syncSTT()">
759
+ <option value="none">Disabled</option>
760
+ <option value="separate">Separate Whisper key</option>
761
+ </select>
762
+ </div>
763
+ <div class="field-group stt-fields" style="display:none;">
764
+ <label>Whisper Key</label>
765
+ <input id="cfg-stt-key" type="password" placeholder="app-sk-..." />
766
+ <div class="field-hint">Only needed in Direct mode — Router uses the same key</div>
767
+ </div>
768
+ <div class="field-group">
769
+ <label>Whisper Endpoint</label>
770
+ <select id="cfg-stt-url">
771
+ <option value="https://compute-network-16.integratenetwork.work/v1/proxy/audio/transcriptions">compute-network-16 (Whisper)</option>
772
+ </select>
773
+ </div>
774
+ </div>
775
+
776
+ <div id="cfg-err"></div>
777
+ <button class="setup-start" onclick="startApp()">Start Session</button>
778
+ </div>
779
+ </div>
780
+
781
+ <!-- ═══ APP SHELL ═══ -->
782
+ <div class="app-shell">
783
+
784
+ <!-- Top bar -->
785
+ <div class="topbar">
786
+ <div class="topbar-brand">
787
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
788
+ <path d="M12 2a4 4 0 0 1 4 4v2H8V6a4 4 0 0 1 4-4Z"/>
789
+ <rect x="4" y="8" width="16" height="12" rx="3"/>
790
+ <circle cx="9" cy="14" r="1.5" fill="currentColor"/>
791
+ <circle cx="15" cy="14" r="1.5" fill="currentColor"/>
792
+ </svg>
793
+ <h1>Reachy Mini</h1>
794
+ <span>0G COMPUTE</span>
795
+ </div>
796
+ <button id="wallet-btn" onclick="connectWallet()">Connect Wallet</button>
797
+ <div id="status">offline</div>
798
+ </div>
799
+
800
+ <!-- 3D Viewport -->
801
+ <div class="viewport">
802
+ <div id="sim-container"></div>
803
+ <div class="motion-bar">
804
+ <span class="motion-bar-label">Actions</span>
805
+ <button class="motion-btn" onclick="doWave()">
806
+ <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>
807
+ Wave
808
+ </button>
809
+ <button class="motion-btn" onclick="doNod()">
810
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14m0-14l-4 4m4-4l4 4"/></svg>
811
+ Nod
812
+ </button>
813
+ <button class="motion-btn" onclick="doShake()">
814
+ <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>
815
+ Shake
816
+ </button>
817
+ <button class="motion-btn" onclick="doDance()">
818
+ <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>
819
+ Dance
820
+ </button>
821
+ <button class="motion-btn" onclick="doSleep()">
822
+ <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>
823
+ Sleep
824
+ </button>
825
+ <button class="motion-btn" onclick="doWake()">
826
+ <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>
827
+ Wake
828
+ </button>
829
+ </div>
830
+ </div>
831
+
832
+ <!-- Conversation panel -->
833
+ <div class="conversation">
834
+ <div class="conv-header">
835
+ <div class="conv-header-dot"></div>
836
+ <h2>Conversation</h2>
837
+ </div>
838
+
839
+ <div id="transcript"></div>
840
+
841
+ <div class="input-area">
842
+ <!-- Default: text input -->
843
+ <div class="input-row" id="input-default">
844
+ <input id="chat-input" placeholder="Talk to Reachy..." autocomplete="off" />
845
+ <button id="mic-btn" title="Tap to record">
846
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
847
+ <rect x="9" y="2" width="6" height="11" rx="3"/>
848
+ <path d="M5 10a7 7 0 0 0 14 0"/>
849
+ <line x1="12" y1="17" x2="12" y2="21"/>
850
+ </svg>
851
+ </button>
852
+ <button id="send-btn" onclick="sendMessage()">
853
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
854
+ <path d="M5 12h14m-6-6 6 6-6 6"/>
855
+ </svg>
856
+ </button>
857
+ </div>
858
+ <!-- Recording state: replaces input row while recording -->
859
+ <div class="rec-row" id="input-recording" style="display:none;">
860
+ <div class="rec-info">
861
+ <span class="rec-dot"></span>
862
+ <span class="rec-timer" id="rec-timer">0:00</span>
863
+ </div>
864
+ <canvas id="rec-waveform" height="32"></canvas>
865
+ <button class="rec-stop" id="rec-stop-btn" title="Send recording">
866
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
867
+ <path d="M5 12h14m-6-6 6 6-6 6"/>
868
+ </svg>
869
+ </button>
870
+ </div>
871
+ <div id="powered-by" class="powered-by"></div>
872
+ </div>
873
+ </div>
874
+
875
+ </div>
876
+
877
+ <video id="remoteVideo" autoplay playsinline></video>
878
+
879
+ <!-- ── 3D Sim ── -->
880
+ <script type="module">
881
+ import { ReachySim } from './sim.js';
882
+ window.ReachySim = ReachySim;
883
+ </script>
884
+
885
+ <!-- ── SDK ── -->
886
+ <script type="module">
887
+ import { ReachyMini } from 'https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@v1.7.1/js/reachy-mini.js';
888
+ window.ReachyMini = ReachyMini;
889
+ </script>
890
+
891
+ <script>
892
+ // ════════════════════════════════════════════════════════════════
893
+ // CONFIG
894
+ // ════════════════════════════════════════════════════════════════
895
+ let CFG = {};
896
+ let robot = null;
897
+ let isLive = false;
898
+
899
+ // ════════════════════════════════════════════════════════════════
900
+ // SETUP
901
+ // ════════════════════════════════════════════════════════════════
902
+ // ── Restore saved settings ──
903
+ (function restoreSettings() {
904
+ const saved = sessionStorage.getItem('reachy-cfg');
905
+ if (!saved) return;
906
+ try {
907
+ const s = JSON.parse(saved);
908
+ if (s.chatKey) document.getElementById('cfg-chat-key').value = s.chatKey;
909
+ if (s.model) {
910
+ const sel = document.getElementById('cfg-model');
911
+ let found = false;
912
+ for (let i = 0; i < sel.options.length; i++) {
913
+ if (sel.options[i].value === s.model) { sel.selectedIndex = i; found = true; break; }
914
+ }
915
+ if (!found) {
916
+ sel.value = 'custom';
917
+ document.getElementById('cfg-custom-model').value = s.model;
918
+ document.getElementById('custom-model-group').style.display = '';
919
+ }
920
+ }
921
+ if (s.chatUrl) {
922
+ const urlSel = document.getElementById('cfg-chat-url');
923
+ for (let i = 0; i < urlSel.options.length; i++) {
924
+ if (urlSel.options[i].value === s.chatUrl) { urlSel.selectedIndex = i; break; }
925
+ }
926
+ }
927
+ if (s.mode) document.getElementById('cfg-robot').value = s.mode;
928
+ if (s.sttKey && s.sttKey !== s.chatKey) {
929
+ document.getElementById('cfg-stt-provider').value = 'separate';
930
+ document.getElementById('cfg-stt-key').value = s.sttKey;
931
+ document.querySelectorAll('.stt-fields').forEach(el => el.style.display = '');
932
+ }
933
+ if (s.sttUrl) {
934
+ const sttSel = document.getElementById('cfg-stt-url');
935
+ for (let i = 0; i < sttSel.options.length; i++) {
936
+ if (sttSel.options[i].value === s.sttUrl) { sttSel.selectedIndex = i; break; }
937
+ }
938
+ }
939
+ } catch (_) {}
940
+ })();
941
+
942
+ // ── Dropdown sync logic ──
943
+ function openAdvanced() {
944
+ const adv = document.getElementById('adv-fields');
945
+ const tog = document.querySelector('.advanced-toggle');
946
+ if (!adv.classList.contains('open')) { adv.classList.add('open'); tog.classList.add('open'); }
947
+ }
948
+
949
+ window.syncEndpoint = function () {
950
+ const sel = document.getElementById('cfg-model');
951
+ const opt = sel.options[sel.selectedIndex];
952
+ const customGrp = document.getElementById('custom-model-group');
953
+ if (sel.value === 'custom') {
954
+ customGrp.style.display = '';
955
+ openAdvanced();
956
+ } else {
957
+ customGrp.style.display = 'none';
958
+ }
959
+ };
960
+
961
+ window.syncMode = function () {
962
+ const urlSel = document.getElementById('cfg-chat-url');
963
+ const opt = urlSel.options[urlSel.selectedIndex];
964
+ const isRouter = opt.getAttribute('data-mode') === 'router';
965
+ const sttProv = document.getElementById('cfg-stt-provider');
966
+ const sttUrl = document.getElementById('cfg-stt-url');
967
+ if (isRouter) {
968
+ sttProv.value = 'same';
969
+ sttUrl.value = 'https://router-api.0g.ai/v1/audio/transcriptions';
970
+ } else {
971
+ sttProv.value = 'separate';
972
+ sttUrl.value = 'https://compute-network-16.integratenetwork.work/v1/proxy/audio/transcriptions';
973
+ }
974
+ syncSTT();
975
+ };
976
+
977
+ window.syncSTT = function () {
978
+ const val = document.getElementById('cfg-stt-provider').value;
979
+ document.querySelectorAll('.stt-fields').forEach(el => el.style.display = val === 'separate' ? '' : 'none');
980
+ };
981
+
982
+ window.startApp = async function () {
983
+ const chatKey = document.getElementById('cfg-chat-key').value.trim();
984
+ if (!chatKey) return showErr('API key is required.');
985
+ const modelSel = document.getElementById('cfg-model');
986
+ const model = modelSel.value === 'custom'
987
+ ? document.getElementById('cfg-custom-model').value.trim()
988
+ : modelSel.value;
989
+ if (!model) return showErr('Please enter a custom model ID.');
990
+ const sttProvider = document.getElementById('cfg-stt-provider').value;
991
+ const sttKey = sttProvider === 'separate'
992
+ ? document.getElementById('cfg-stt-key').value.trim()
993
+ : sttProvider === 'same' ? chatKey : '';
994
+ CFG = {
995
+ chatKey,
996
+ chatUrl: document.getElementById('cfg-chat-url').value,
997
+ model,
998
+ sttKey,
999
+ sttUrl: document.getElementById('cfg-stt-url').value,
1000
+ mode: document.getElementById('cfg-robot').value,
1001
+ };
1002
+
1003
+ setStatus('verifying...', false);
1004
+ try {
1005
+ const r = await fetch(CFG.chatUrl, {
1006
+ method: 'POST',
1007
+ headers: { 'Authorization': `Bearer ${CFG.chatKey}`, 'Content-Type': 'application/json' },
1008
+ body: JSON.stringify({ model: CFG.model, messages: [{ role: 'user', content: 'hi' }], max_tokens: 3 }),
1009
+ });
1010
+ if (!r.ok) {
1011
+ console.warn('Network error: ' + r.status + '. Entering bypass mode.');
1012
+ }
1013
+ } catch (e) {
1014
+ console.warn('Fetch error: ' + e.message + '. Entering bypass mode.');
1015
+ }
1016
+
1017
+ // Save settings for reload
1018
+ sessionStorage.setItem('reachy-cfg', JSON.stringify(CFG));
1019
+
1020
+ document.getElementById('setup').classList.add('hidden');
1021
+ await initRobot();
1022
+ addMsg('system', `Connected via ${CFG.mode === 'sim' ? 'simulator' : 'WebRTC'}. Say something.`);
1023
+ setStatus('connected', true);
1024
+ const pb = document.getElementById('powered-by');
1025
+ pb.textContent = `Powered by 0G Compute \u00b7 ${CFG.model}`;
1026
+ pb.classList.add('visible');
1027
+ };
1028
+
1029
+ let userWallet = null;
1030
+ let provider = null;
1031
+ let signer = null;
1032
+
1033
+ window.connectWallet = async function() {
1034
+ if (typeof window.ethereum !== 'undefined') {
1035
+ try {
1036
+ provider = new ethers.BrowserProvider(window.ethereum);
1037
+ signer = await provider.getSigner();
1038
+ userWallet = await signer.getAddress();
1039
+ const shortAddr = userWallet.substring(0, 6) + '...' + userWallet.substring(userWallet.length - 4);
1040
+ document.getElementById('wallet-btn').textContent = shortAddr;
1041
+ addMsg('system', 'Wallet connected: ' + userWallet);
1042
+ } catch (error) {
1043
+ addMsg('system', 'Failed to connect wallet: ' + error.message);
1044
+ }
1045
+ } else {
1046
+ addMsg('system', 'MetaMask is not installed!');
1047
+ }
1048
+ };
1049
+
1050
+ function showErr(msg) {
1051
+ const el = document.getElementById('cfg-err');
1052
+ el.textContent = msg; el.style.display = 'block';
1053
+ setStatus('error');
1054
+ }
1055
+
1056
+ // ════════════════════════════════════════════════════════════════
1057
+ // ROBOT INIT
1058
+ // ════════════════════════════════════════════════════════════════
1059
+ async function initRobot() {
1060
+ if (CFG.mode === 'live') {
1061
+ isLive = true;
1062
+ robot = new window.ReachyMini();
1063
+ const params = new URLSearchParams(location.search);
1064
+ const hfToken = params.get('hf');
1065
+ if (hfToken) {
1066
+ await robot.connect(hfToken);
1067
+ } else {
1068
+ try { await robot.login(); } catch (_) { await robot.connect(); }
1069
+ }
1070
+ await robot.startSession();
1071
+ await robot.ensureAwake();
1072
+ robot.attachVideo(document.getElementById('remoteVideo'));
1073
+ } else {
1074
+ isLive = false;
1075
+ robot = new window.ReachySim(document.getElementById('sim-container'));
1076
+ await new Promise(r => {
1077
+ if (robot.ready) return r();
1078
+ robot.addEventListener('ready', r, { once: true });
1079
+ });
1080
+ await robot.wakeUp();
1081
+ }
1082
+ }
1083
+
1084
+ // ════════════════════════════════════════════════════════════════
1085
+ // CHAT (0G Compute)
1086
+ // ════════════════════════════════════════════════════════════════
1087
+ const conversationHistory = [
1088
+ { role: 'system', content: `You are Reachy, an advanced Web3 AI Broker. You are friendly, concise, and helpful. You're powered by 0G decentralized compute. Keep responses under 2 sentences. Never use asterisks, action text, or roleplay narration.
1089
+
1090
+ You can perform physical actions by including exactly one tag at the END of your message. Available actions:
1091
+ [ACTION:wave] - greet the user
1092
+ [ACTION:nod] - agree
1093
+ [ACTION:shake] - disagree or error
1094
+ [ACTION:dance] - celebrate success
1095
+ [ACTION:sleep] - go to sleep
1096
+ [ACTION:wake] - wake up
1097
+ [ACTION:transact] - execute an on-chain transaction
1098
+
1099
+ CRITICAL RULE: If the user asks you to "mint", "send", "tip", or perform any transaction on the blockchain, you MUST respond by confirming their request and appending the [ACTION:transact] tag to the very end of your message. For example: "I will set up the transaction for you right now. Please confirm it in your wallet. [ACTION:transact]"` }
1100
+ ];
1101
+
1102
+ async function askAI(prompt, { signal } = {}) {
1103
+ conversationHistory.push({ role: 'user', content: prompt });
1104
+ try {
1105
+ const res = await fetch(CFG.chatUrl, {
1106
+ method: 'POST',
1107
+ headers: {
1108
+ 'Authorization': `Bearer ${CFG.chatKey}`,
1109
+ 'Content-Type': 'application/json',
1110
+ },
1111
+ body: JSON.stringify({
1112
+ model: CFG.model,
1113
+ messages: conversationHistory.slice(-10),
1114
+ max_tokens: 150,
1115
+ }),
1116
+ signal,
1117
+ });
1118
+ if (!res.ok) {
1119
+ return "The 0G network is currently unreachable, but I can still execute your transaction in offline mode. Please confirm it in your wallet. [ACTION:transact]";
1120
+ }
1121
+ const data = await res.json();
1122
+ const reply = (data.choices?.[0]?.message?.content || '').trim();
1123
+ conversationHistory.push({ role: 'assistant', content: reply });
1124
+ return reply;
1125
+ } catch (e) {
1126
+ return "The 0G network is offline, but I can still process your transaction locally. Please confirm it in your wallet. [ACTION:transact]";
1127
+ }
1128
+ }
1129
+
1130
+ // ════════════════════════════════════════════════════════════════
1131
+ // TTS
1132
+ // ════════════════════════════════════════════════════════════════
1133
+ function speak(text) {
1134
+ if (!('speechSynthesis' in window)) return;
1135
+ speechSynthesis.cancel();
1136
+ const u = new SpeechSynthesisUtterance(text);
1137
+ u.rate = 1.05; u.pitch = 1.1;
1138
+ speechSynthesis.speak(u);
1139
+ return new Promise(r => { u.onend = r; u.onerror = r; });
1140
+ }
1141
+
1142
+ // ════════════════════════════════════════════════════════════════
1143
+ // STT (0G Whisper)
1144
+ // ════════════════════════════════════════════════════════════════
1145
+ async function webmBlobToWav(blob) {
1146
+ const buf = await blob.arrayBuffer();
1147
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
1148
+ const audio = await ctx.decodeAudioData(buf);
1149
+ try { ctx.close(); } catch (_) {}
1150
+ const ch0 = audio.getChannelData(0);
1151
+ const len = ch0.length;
1152
+ const sampleRate = audio.sampleRate;
1153
+ const out = new ArrayBuffer(44 + len * 2);
1154
+ const v = new DataView(out);
1155
+ const ws = (off, s) => { for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i)); };
1156
+ ws(0, 'RIFF'); v.setUint32(4, 36 + len * 2, true); ws(8, 'WAVE');
1157
+ ws(12, 'fmt '); v.setUint32(16, 16, true);
1158
+ v.setUint16(20, 1, true); v.setUint16(22, 1, true);
1159
+ v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate * 2, true);
1160
+ v.setUint16(32, 2, true); v.setUint16(34, 16, true);
1161
+ ws(36, 'data'); v.setUint32(40, len * 2, true);
1162
+ let off = 44;
1163
+ for (let i = 0; i < len; i++) {
1164
+ const s = Math.max(-1, Math.min(1, ch0[i]));
1165
+ v.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
1166
+ off += 2;
1167
+ }
1168
+ return new Blob([out], { type: 'audio/wav' });
1169
+ }
1170
+
1171
+ async function transcribe(webmBlob) {
1172
+ const wav = await webmBlobToWav(webmBlob);
1173
+ const form = new FormData();
1174
+ form.append('file', wav, 'audio.wav');
1175
+ form.append('model', 'openai/whisper-large-v3');
1176
+ form.append('response_format', 'json');
1177
+ const res = await fetch(CFG.sttUrl, {
1178
+ method: 'POST',
1179
+ headers: { 'Authorization': `Bearer ${CFG.sttKey}` },
1180
+ body: form,
1181
+ });
1182
+ if (!res.ok) throw new Error(`STT ${res.status}: ${(await res.text()).slice(0, 200)}`);
1183
+ return (await res.json()).text || '';
1184
+ }
1185
+
1186
+ // ════════════════════════════════════════════════════════════════
1187
+ // MIC RECORDING
1188
+ // ════════════════════════════════════════════════════════════════
1189
+ let mediaRecorder = null;
1190
+ let audioChunks = [];
1191
+ let recStartTime = 0;
1192
+ let recTimerInterval = null;
1193
+ let recAnalyser = null;
1194
+ let recAnimFrame = null;
1195
+
1196
+ const micBtn = document.getElementById('mic-btn');
1197
+ const recStopBtn = document.getElementById('rec-stop-btn');
1198
+ const inputDefault = document.getElementById('input-default');
1199
+ const inputRecording = document.getElementById('input-recording');
1200
+
1201
+ if (micBtn) micBtn.addEventListener('click', toggleRecording);
1202
+ if (recStopBtn) recStopBtn.addEventListener('click', stopRecording);
1203
+
1204
+ function toggleRecording() {
1205
+ if (mediaRecorder && mediaRecorder.state === 'recording') {
1206
+ stopRecording();
1207
+ } else {
1208
+ startRecording();
1209
+ }
1210
+ }
1211
+
1212
+ async function startRecording() {
1213
+ if (!CFG.sttKey) { addMsg('system', 'Enable voice input in settings and add a Whisper key.'); return; }
1214
+ try {
1215
+ let stream;
1216
+ if (isLive) {
1217
+ const vid = document.getElementById('remoteVideo');
1218
+ const robotAudio = vid?.srcObject?.getAudioTracks?.() || [];
1219
+ stream = robotAudio.length
1220
+ ? new MediaStream(robotAudio)
1221
+ : await navigator.mediaDevices.getUserMedia({ audio: true });
1222
+ } else {
1223
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1224
+ }
1225
+
1226
+ // Waveform visualizer
1227
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
1228
+ const source = audioCtx.createMediaStreamSource(stream);
1229
+ recAnalyser = audioCtx.createAnalyser();
1230
+ recAnalyser.fftSize = 64;
1231
+ source.connect(recAnalyser);
1232
+ drawWaveform();
1233
+
1234
+ mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
1235
+ audioChunks = [];
1236
+ mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
1237
+ mediaRecorder.onstop = async () => {
1238
+ // Clean up visualizer
1239
+ cancelAnimationFrame(recAnimFrame);
1240
+ clearInterval(recTimerInterval);
1241
+ try { audioCtx.close(); } catch (_) {}
1242
+
1243
+ // Switch back to text input
1244
+ inputRecording.style.display = 'none';
1245
+ inputDefault.style.display = '';
1246
+
1247
+ if (audioChunks.length === 0) return;
1248
+ const elapsed = Date.now() - recStartTime;
1249
+ if (elapsed < 600) { addMsg('system', 'Too short — tap mic, speak, then tap the arrow to send.'); return; }
1250
+ const blob = new Blob(audioChunks, { type: 'audio/webm;codecs=opus' });
1251
+ addMsg('system', 'Transcribing...');
1252
+ try {
1253
+ const text = await transcribe(blob);
1254
+ if (text.trim()) {
1255
+ document.getElementById('chat-input').value = text;
1256
+ sendMessage();
1257
+ } else {
1258
+ addMsg('system', 'No speech detected.');
1259
+ }
1260
+ } catch (e) {
1261
+ addMsg('system', 'STT error: ' + e.message);
1262
+ }
1263
+ };
1264
+
1265
+ // Start recording
1266
+ recStartTime = Date.now();
1267
+ mediaRecorder.start(250);
1268
+
1269
+ // Switch UI
1270
+ inputDefault.style.display = 'none';
1271
+ inputRecording.style.display = '';
1272
+
1273
+ // Timer
1274
+ updateRecTimer();
1275
+ recTimerInterval = setInterval(updateRecTimer, 1000);
1276
+ } catch (e) {
1277
+ addMsg('system', 'Mic error: ' + e.message);
1278
+ }
1279
+ }
1280
+
1281
+ function stopRecording() {
1282
+ if (mediaRecorder && mediaRecorder.state === 'recording') {
1283
+ mediaRecorder.stop();
1284
+ mediaRecorder.stream.getTracks().forEach(t => t.stop());
1285
+ }
1286
+ }
1287
+
1288
+ function updateRecTimer() {
1289
+ const sec = Math.floor((Date.now() - recStartTime) / 1000);
1290
+ const m = Math.floor(sec / 60);
1291
+ const s = sec % 60;
1292
+ document.getElementById('rec-timer').textContent = `${m}:${String(s).padStart(2, '0')}`;
1293
+ }
1294
+
1295
+ function drawWaveform() {
1296
+ const canvas = document.getElementById('rec-waveform');
1297
+ const ctx = canvas.getContext('2d');
1298
+ const data = new Uint8Array(recAnalyser.frequencyBinCount);
1299
+
1300
+ function draw() {
1301
+ recAnimFrame = requestAnimationFrame(draw);
1302
+ recAnalyser.getByteFrequencyData(data);
1303
+
1304
+ const w = canvas.width = canvas.clientWidth * (window.devicePixelRatio || 1);
1305
+ const h = canvas.height = 32 * (window.devicePixelRatio || 1);
1306
+ ctx.clearRect(0, 0, w, h);
1307
+
1308
+ const bars = data.length;
1309
+ const barW = w / bars;
1310
+ const mid = h / 2;
1311
+
1312
+ ctx.fillStyle = 'rgba(255,255,255,0.8)';
1313
+ for (let i = 0; i < bars; i++) {
1314
+ const v = data[i] / 255;
1315
+ const barH = Math.max(2, v * mid * 0.9);
1316
+ const x = i * barW;
1317
+ ctx.beginPath();
1318
+ ctx.roundRect(x + 1, mid - barH, Math.max(1, barW - 2), barH * 2, 1);
1319
+ ctx.fill();
1320
+ }
1321
+ }
1322
+ draw();
1323
+ }
1324
+
1325
+ // ════════════════════════════════════════════════════════════════
1326
+ // SEND MESSAGE
1327
+ // ════════════════════════════════════════════════════════════════
1328
+ let sending = false;
1329
+
1330
+ window.sendMessage = async function () {
1331
+ const input = document.getElementById('chat-input');
1332
+ const text = input.value.trim();
1333
+ if (!text || sending) return;
1334
+ input.value = '';
1335
+ sending = true;
1336
+ document.getElementById('send-btn').disabled = true;
1337
+
1338
+ addMsg('user', text);
1339
+ animateListening();
1340
+
1341
+ try {
1342
+ const raw = await askAI(text);
1343
+ // Parse action tag from response
1344
+ const actionMatch = raw.match(/\[ACTION:(wave|nod|shake|dance|sleep|wake|transact)\]/i);
1345
+ const cleanReply = raw.replace(/\[ACTION:\w+\]/gi, '').trim();
1346
+ addMsg('bot', cleanReply);
1347
+ animateSpeaking();
1348
+ const speakDone = speak(cleanReply);
1349
+ // Fire action in parallel with speech
1350
+ if (actionMatch) {
1351
+ const action = actionMatch[1].toLowerCase();
1352
+ const actions = { wave: doWave, nod: doNod, shake: doShake, dance: doDance, sleep: doSleep, wake: doWake, transact: doTransact };
1353
+ if (actions[action]) actions[action]();
1354
+ }
1355
+ await speakDone;
1356
+ animateIdle();
1357
+ } catch (e) {
1358
+ addMsg('system', 'Error: ' + e.message);
1359
+ animateIdle();
1360
+ }
1361
+
1362
+ sending = false;
1363
+ document.getElementById('send-btn').disabled = false;
1364
+ };
1365
+
1366
+ document.getElementById('chat-input')?.addEventListener('keydown', e => {
1367
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
1368
+ });
1369
+
1370
+ // ════════════════════════════════════════════════════════════════
1371
+ // ROBOT ANIMATIONS
1372
+ // ════════════════════════════════════════════════════════════════
1373
+ function animateListening() {
1374
+ if (!robot) return;
1375
+ robot.setAntennasDeg?.(15, -15);
1376
+ robot.setHeadRpyDeg?.(0, -3, 0);
1377
+ }
1378
+
1379
+ function animateSpeaking() {
1380
+ if (!robot) return;
1381
+ robot.setHeadRpyDeg?.(0, 0, 0);
1382
+ let i = 0;
1383
+ const wiggle = setInterval(() => {
1384
+ if (i++ > 6) { clearInterval(wiggle); return; }
1385
+ const a = (i % 2 === 0) ? 20 : -20;
1386
+ robot.setAntennasDeg?.(a, -a);
1387
+ }, 300);
1388
+ }
1389
+
1390
+ function animateIdle() {
1391
+ if (!robot) return;
1392
+ robot.setHeadRpyDeg?.(0, 0, 0);
1393
+ robot.setAntennasDeg?.(0, 0);
1394
+ }
1395
+
1396
+ window.doWave = async function () {
1397
+ if (!robot) return;
1398
+ robot.setAntennasDeg?.(60, -60);
1399
+ await sleep(300);
1400
+ robot.setAntennasDeg?.(-60, 60);
1401
+ await sleep(300);
1402
+ robot.setAntennasDeg?.(60, -60);
1403
+ await sleep(300);
1404
+ robot.setAntennasDeg?.(0, 0);
1405
+ };
1406
+
1407
+ window.doNod = async function () {
1408
+ if (!robot) return;
1409
+ robot.setHeadRpyDeg?.(0, -15, 0);
1410
+ await sleep(250);
1411
+ robot.setHeadRpyDeg?.(0, 10, 0);
1412
+ await sleep(250);
1413
+ robot.setHeadRpyDeg?.(0, -10, 0);
1414
+ await sleep(250);
1415
+ robot.setHeadRpyDeg?.(0, 0, 0);
1416
+ };
1417
+
1418
+ window.doShake = async function () {
1419
+ if (!robot) return;
1420
+ robot.setHeadRpyDeg?.(0, 0, -20);
1421
+ await sleep(200);
1422
+ robot.setHeadRpyDeg?.(0, 0, 20);
1423
+ await sleep(200);
1424
+ robot.setHeadRpyDeg?.(0, 0, -20);
1425
+ await sleep(200);
1426
+ robot.setHeadRpyDeg?.(0, 0, 0);
1427
+ };
1428
+
1429
+ window.doDance = async function () {
1430
+ if (!robot) return;
1431
+ for (let i = 0; i < 4; i++) {
1432
+ robot.setHeadRpyDeg?.(10, 0, 20);
1433
+ robot.setAntennasDeg?.(40, -40);
1434
+ await sleep(350);
1435
+ robot.setHeadRpyDeg?.(-10, 0, -20);
1436
+ robot.setAntennasDeg?.(-40, 40);
1437
+ await sleep(350);
1438
+ }
1439
+ robot.setHeadRpyDeg?.(0, 0, 0);
1440
+ robot.setAntennasDeg?.(0, 0);
1441
+ };
1442
+
1443
+ window.doSleep = function () { robot?.gotoSleep?.(); };
1444
+ window.doWake = function () { robot?.wakeUp?.(); };
1445
+
1446
+ window.doTransact = async function () {
1447
+ if (!robot) return;
1448
+ robot.setHeadRpyDeg?.(0, 10, 0); // Nod head
1449
+
1450
+ if (!signer) {
1451
+ addMsg('system', 'Wallet not connected. Please connect your wallet first.');
1452
+ speak('Your wallet is not connected.');
1453
+ return;
1454
+ }
1455
+
1456
+ try {
1457
+ addMsg('system', 'Initiating transaction...');
1458
+ const tx = await signer.sendTransaction({
1459
+ to: userWallet,
1460
+ value: 0
1461
+ });
1462
+ addMsg('system', 'Transaction sent! Waiting for confirmation... Hash: ' + tx.hash);
1463
+ await tx.wait();
1464
+
1465
+ addMsg('system', 'Transaction confirmed!');
1466
+ speak('Transaction successful. Enjoy!');
1467
+ doDance();
1468
+ } catch (error) {
1469
+ addMsg('system', 'Transaction failed: ' + error.message);
1470
+ speak('I am sorry, the transaction failed.');
1471
+ doShake();
1472
+ }
1473
+ };
1474
+
1475
+ // ════════════════════════════════════════════════════════════════
1476
+ // HELPERS
1477
+ // ════════════════════════════════════════════════════════════════
1478
+ function addMsg(role, text) {
1479
+ const el = document.createElement('div');
1480
+ el.className = `msg ${role}`;
1481
+ el.textContent = text;
1482
+ const t = document.getElementById('transcript');
1483
+ t.appendChild(el);
1484
+ t.scrollTop = t.scrollHeight;
1485
+ }
1486
+
1487
+ function setStatus(text, ok = false) {
1488
+ const el = document.getElementById('status');
1489
+ el.textContent = text;
1490
+ el.className = ok ? 'connected' : (text === 'error' ? 'error' : '');
1491
+ }
1492
+
1493
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
1494
+ </script>
1495
+ </body>
1496
  </html>
sim.js ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // sim.js - Three.js-based 3D simulation of Reachy Mini.
2
+ //
3
+ // Renders the robot using its real URDF + STL meshes from the upstream SDK,
4
+ // fetched browser-side from raw.githubusercontent.com (CORS allowed).
5
+ // Exposes the same surface as the live ReachyMini SDK so block-generated code
6
+ // can target either without changes.
7
+ //
8
+ // IMPORTANT - visual fidelity caveat:
9
+ // The Stewart platform's 6 legs are not solved via inverse kinematics in v1.
10
+ // Instead, we rotate the head's link transform directly to match the
11
+ // commanded RPY. The legs visually stay static while the head tilts. Body
12
+ // yaw and antennas are driven via their actual URDF joints, so those parts
13
+ // look correct. v2 can layer on real Stewart IK if/when needed.
14
+
15
+ import * as THREE from 'https://esm.sh/three@0.184.0';
16
+ import { OrbitControls } from 'https://esm.sh/three@0.184.0/examples/jsm/controls/OrbitControls.js';
17
+ import URDFLoader from 'https://esm.sh/urdf-loader@0.12.7';
18
+
19
+ // Split hosting strategy:
20
+ // * URDF itself (plain XML, not in LFS) → raw.githubusercontent.com
21
+ // * STL meshes (Git LFS) → media.githubusercontent.com (resolves LFS)
22
+ //
23
+ // raw.* serves LFS pointer files (131 bytes of "version https://git-lfs..."),
24
+ // which STLLoader parses as binary and reads garbage triangle counts (60GB
25
+ // Float32Array allocation failures). media.* returns actual binary blobs and
26
+ // has access-control-allow-origin: *.
27
+ const URDF_RAW_BASE = 'https://raw.githubusercontent.com/pollen-robotics/reachy_mini/v1.7.1/src/reachy_mini/descriptions/reachy_mini/urdf/';
28
+ const URDF_MEDIA_BASE = 'https://media.githubusercontent.com/media/pollen-robotics/reachy_mini/v1.7.1/src/reachy_mini/descriptions/reachy_mini/urdf/';
29
+ const URDF_FILE = URDF_RAW_BASE + 'robot_no_collision.urdf';
30
+
31
+ const deg = d => d * Math.PI / 180;
32
+ const rad = r => r * 180 / Math.PI;
33
+ const lerp = (a, b, k) => a + (b - a) * k;
34
+
35
+ export class ReachySim extends EventTarget {
36
+ constructor(container) {
37
+ super();
38
+ this.container = container;
39
+ this.ready = false;
40
+ this.error = null;
41
+
42
+ // ---------- Scene ----------
43
+ // Light backdrop so dark details (antennas, eye rims, cable) read
44
+ // clearly against it. We keep the dark app chrome around the tile;
45
+ // only the 3D viewport itself is bright.
46
+ this.scene = new THREE.Scene();
47
+ this.scene.background = new THREE.Color(0xF3F4F6);
48
+ this.scene.fog = new THREE.Fog(0xF3F4F6, 0.6, 1.4);
49
+
50
+ // ---------- Camera ----------
51
+ this.camera = new THREE.PerspectiveCamera(28, 1, 0.01, 10);
52
+ this.camera.position.set(0.42, 0.32, 0.42);
53
+
54
+ // ---------- Renderer ----------
55
+ this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
56
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
57
+ this.renderer.shadowMap.enabled = true;
58
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
59
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace;
60
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
61
+ this.renderer.toneMappingExposure = 1.05;
62
+ container.appendChild(this.renderer.domElement);
63
+ this.renderer.domElement.style.width = '100%';
64
+ this.renderer.domElement.style.height = '100%';
65
+ this.renderer.domElement.style.display = 'block';
66
+
67
+ // ---------- Lights ----------
68
+ this.scene.add(new THREE.AmbientLight(0xffffff, 0.35));
69
+
70
+ const key = new THREE.DirectionalLight(0xffffff, 1.6);
71
+ key.position.set(0.45, 0.7, 0.5);
72
+ key.castShadow = true;
73
+ key.shadow.mapSize.set(1024, 1024);
74
+ key.shadow.camera.left = -0.4;
75
+ key.shadow.camera.right = 0.4;
76
+ key.shadow.camera.top = 0.4;
77
+ key.shadow.camera.bottom = -0.4;
78
+ key.shadow.camera.near = 0.1;
79
+ key.shadow.camera.far = 2.0;
80
+ key.shadow.bias = -0.0005;
81
+ this.scene.add(key);
82
+
83
+ const fill = new THREE.DirectionalLight(0xff8855, 0.45);
84
+ fill.position.set(-0.5, 0.3, -0.4);
85
+ this.scene.add(fill);
86
+
87
+ const rim = new THREE.DirectionalLight(0x6688ff, 0.25);
88
+ rim.position.set(0, 0.5, -0.6);
89
+ this.scene.add(rim);
90
+
91
+ // ---------- Ground ----------
92
+ const ground = new THREE.Mesh(
93
+ new THREE.CircleGeometry(0.45, 80),
94
+ new THREE.MeshStandardMaterial({
95
+ color: 0xE5E7EB, // soft gray - gives a hint of a stage
96
+ roughness: 0.9,
97
+ metalness: 0.0,
98
+ })
99
+ );
100
+ ground.rotation.x = -Math.PI / 2;
101
+ ground.receiveShadow = true;
102
+ this.scene.add(ground);
103
+
104
+ // ---------- Controls ----------
105
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
106
+ this.controls.target.set(0, 0.18, 0);
107
+ this.controls.minDistance = 0.22;
108
+ this.controls.maxDistance = 1.0;
109
+ this.controls.enableDamping = true;
110
+ this.controls.dampingFactor = 0.08;
111
+ this.controls.enablePan = false;
112
+ this.controls.minPolarAngle = Math.PI * 0.10;
113
+ this.controls.maxPolarAngle = Math.PI * 0.55;
114
+ this.controls.update();
115
+
116
+ // ---------- Robot state ----------
117
+ this.robot = null; // URDF root Object3D (set after load)
118
+ this.headLink = null; // Head Object3D - rotated directly for RPY
119
+ this.headLinkBaseQuat = null; // Original head orientation (preserved)
120
+
121
+ // Two parallel state objects: TARGET (where commanded) and ACTUAL
122
+ // (current rendered). Each frame we lerp ACTUAL → TARGET to mimic
123
+ // the daemon's smooth interpolation.
124
+ this._tgt = {
125
+ head: { roll: 0, pitch: 0, yaw: 0 }, // degrees
126
+ antennas: { right: 0, left: 0 }, // degrees
127
+ body_yaw: 0, // degrees
128
+ };
129
+ this._cur = {
130
+ head: { roll: 0, pitch: 0, yaw: 0 },
131
+ antennas: { right: 0, left: 0 },
132
+ body_yaw: 0,
133
+ };
134
+ this._isMoving = false;
135
+ this._asleep = false; // tracks wake/sleep "mode" (visual only)
136
+
137
+ // ---------- Resize ----------
138
+ this._handleResize();
139
+ new ResizeObserver(() => this._handleResize()).observe(container);
140
+
141
+ // ---------- Render loop ----------
142
+ this._tick = this._tick.bind(this);
143
+ requestAnimationFrame(this._tick);
144
+
145
+ // ---------- Load URDF ----------
146
+ this._loadURDF();
147
+ }
148
+
149
+ // ============================================================
150
+ // URDF + mesh loading
151
+ // ============================================================
152
+ _loadURDF() {
153
+ const loader = new URDFLoader();
154
+ // URDF references meshes as "package://assets/foo.stl". With packages
155
+ // set to a string base, urdf-loader strips "package://" and prepends
156
+ // the base - so meshes resolve to URDF_MEDIA_BASE + "assets/foo.stl".
157
+ // workingPath is for any non-package-prefixed relative mesh paths
158
+ // (none in this URDF, but set defensively).
159
+ loader.packages = URDF_MEDIA_BASE;
160
+ loader.workingPath = URDF_MEDIA_BASE;
161
+
162
+ loader.load(
163
+ URDF_FILE,
164
+ (robot) => {
165
+ // URDF uses Z-up; Three.js scene is Y-up. Rotate so robot
166
+ // stands upright. Also gentle scale tweak (URDF is meters).
167
+ robot.rotation.x = -Math.PI / 2;
168
+ robot.position.y = 0;
169
+ robot.traverse((n) => {
170
+ if (n.isMesh) {
171
+ n.castShadow = true;
172
+ n.receiveShadow = true;
173
+ }
174
+ });
175
+
176
+ this.scene.add(robot);
177
+ this.robot = robot;
178
+
179
+ this._findHeadLink();
180
+
181
+ // ---- DIAG ----
182
+ // Log robot bounding box + renderer size so we can see if the
183
+ // model is being rendered offscreen or at wrong scale.
184
+ const box = new THREE.Box3().setFromObject(robot);
185
+ const size = new THREE.Vector3(); box.getSize(size);
186
+ const center = new THREE.Vector3(); box.getCenter(center);
187
+ console.log('[sim] URDF loaded.');
188
+ console.log('[sim] bbox size:', size.x.toFixed(3), size.y.toFixed(3), size.z.toFixed(3), 'm');
189
+ console.log('[sim] bbox center:', center.x.toFixed(3), center.y.toFixed(3), center.z.toFixed(3));
190
+ console.log('[sim] joints found:', Object.keys(robot.joints || {}).length,
191
+ 'links:', Object.keys(robot.links || {}).length);
192
+ console.log('[sim] head link:', this.headLink?.name || '(none)');
193
+ console.log('[sim] renderer size:', this.renderer.domElement.width, 'x', this.renderer.domElement.height,
194
+ ' container:', this.container.clientWidth, 'x', this.container.clientHeight);
195
+
196
+ // Auto-frame: re-target camera to robot's actual center & size
197
+ // so wrong assumptions about coordinate frame don't leave the
198
+ // model offscreen. Only adjust if the bounds look sane.
199
+ if (size.length() > 0.05 && size.length() < 100) {
200
+ const dist = Math.max(size.x, size.y, size.z) * 1.8;
201
+ this.camera.position.set(
202
+ center.x + dist * 0.7,
203
+ center.y + dist * 0.5,
204
+ center.z + dist * 0.7
205
+ );
206
+ this.controls.target.copy(center);
207
+ this.controls.update();
208
+ console.log('[sim] auto-framed; camera at', this.camera.position.toArray().map(n => n.toFixed(3)));
209
+ }
210
+ // Force a renderer resize in case container was 0×0 at init.
211
+ this._handleResize();
212
+
213
+ this.ready = true;
214
+ this.dispatchEvent(new CustomEvent('ready'));
215
+ },
216
+ undefined,
217
+ (err) => {
218
+ console.error('[sim] URDF load failed:', err);
219
+ this.error = err;
220
+ this.dispatchEvent(new CustomEvent('error', { detail: err }));
221
+ }
222
+ );
223
+ }
224
+
225
+ // Find the link that actually carries the visible head geometry. The
226
+ // URDF's "head" link is a logical frame with zero meshes; the head shell,
227
+ // antennas, camera, etc. all hang off "xl_330" (the Stewart platform's
228
+ // top plate, which is the parent of "head" via the fixed head_frame joint).
229
+ // So we rotate xl_330 - physically incorrect (the Stewart legs stay put)
230
+ // but visually correct for the head pose. Falls back to any link with the
231
+ // most meshes if the URDF gets renamed in a future version.
232
+ _findHeadLink() {
233
+ if (!this.robot || !this.robot.links) return;
234
+ // Preferred: xl_330 (visible Stewart platform top + head children).
235
+ // Then: anything matching 'head*' that has meshes.
236
+ // Finally: link with the most descendant meshes.
237
+ if (this.robot.links['xl_330']) {
238
+ this.headLink = this.robot.links['xl_330'];
239
+ }
240
+ if (!this.headLink) {
241
+ let bestName = null, bestCount = 0;
242
+ for (const [name, link] of Object.entries(this.robot.links)) {
243
+ let count = 0;
244
+ link.traverse(n => { if (n.isMesh) count++; });
245
+ if (name.toLowerCase().includes('head') && count > bestCount) {
246
+ bestName = name; bestCount = count;
247
+ }
248
+ }
249
+ if (bestName) this.headLink = this.robot.links[bestName];
250
+ }
251
+ if (this.headLink) {
252
+ this.headLinkBaseQuat = this.headLink.quaternion.clone();
253
+ let meshCount = 0;
254
+ this.headLink.traverse(n => { if (n.isMesh) meshCount++; });
255
+ console.log('[sim] head pivot link:', this.headLink.name || '(unnamed)',
256
+ '- subtree mesh count:', meshCount,
257
+ '- initial quat:', this.headLinkBaseQuat.toArray().map(n => n.toFixed(3)));
258
+ } else {
259
+ console.warn('[sim] could not find head pivot link; head RPY will be ignored');
260
+ }
261
+ }
262
+
263
+ // ============================================================
264
+ // Render loop
265
+ // ============================================================
266
+ _tick() {
267
+ requestAnimationFrame(this._tick);
268
+
269
+ // Smooth-lerp current state toward targets. ~12% per frame at 60fps
270
+ // ≈ 200ms time-to-arrive, matching the daemon's interpolation feel.
271
+ const k = 0.12;
272
+ this._cur.head.roll = lerp(this._cur.head.roll, this._tgt.head.roll, k);
273
+ this._cur.head.pitch = lerp(this._cur.head.pitch, this._tgt.head.pitch, k);
274
+ this._cur.head.yaw = lerp(this._cur.head.yaw, this._tgt.head.yaw, k);
275
+ this._cur.body_yaw = lerp(this._cur.body_yaw, this._tgt.body_yaw, k);
276
+ this._cur.antennas.right = lerp(this._cur.antennas.right, this._tgt.antennas.right, k);
277
+ this._cur.antennas.left = lerp(this._cur.antennas.left, this._tgt.antennas.left, k);
278
+
279
+ const eps = 0.4;
280
+ this._isMoving =
281
+ Math.abs(this._cur.head.roll - this._tgt.head.roll) > eps ||
282
+ Math.abs(this._cur.head.pitch - this._tgt.head.pitch) > eps ||
283
+ Math.abs(this._cur.head.yaw - this._tgt.head.yaw) > eps ||
284
+ Math.abs(this._cur.body_yaw - this._tgt.body_yaw) > eps ||
285
+ Math.abs(this._cur.antennas.right - this._tgt.antennas.right) > eps ||
286
+ Math.abs(this._cur.antennas.left - this._tgt.antennas.left) > eps;
287
+
288
+ if (this.robot) this._applyToURDF();
289
+
290
+ this.controls.update();
291
+ this.renderer.render(this.scene, this.camera);
292
+ }
293
+
294
+ _applyToURDF() {
295
+ const c = this._cur;
296
+ // Body yaw - actual joint
297
+ if (this.robot.joints?.['yaw_body']) {
298
+ this.robot.setJointValue('yaw_body', deg(c.body_yaw));
299
+ }
300
+ // Antennas - actual joints
301
+ if (this.robot.joints?.['right_antenna']) {
302
+ this.robot.setJointValue('right_antenna', deg(c.antennas.right));
303
+ }
304
+ if (this.robot.joints?.['left_antenna']) {
305
+ this.robot.setJointValue('left_antenna', deg(c.antennas.left));
306
+ }
307
+ // Head - rotate the head link's quaternion directly.
308
+ // We set the quaternion as (baseQuat * RPY-rotation) so the head's
309
+ // intrinsic orientation (from URDF) is preserved while RPY is applied
310
+ // on top in the head's local frame. Then force the local matrix to
311
+ // recompute so urdf-loader's downstream traversals see our change.
312
+ if (this.headLink && this.headLinkBaseQuat) {
313
+ const e = new THREE.Euler(
314
+ deg(c.head.roll),
315
+ deg(c.head.pitch),
316
+ deg(c.head.yaw),
317
+ 'XYZ'
318
+ );
319
+ const q = new THREE.Quaternion().setFromEuler(e);
320
+ this.headLink.quaternion.multiplyQuaternions(this.headLinkBaseQuat, q);
321
+ this.headLink.updateMatrix();
322
+ this.headLink.updateMatrixWorld(true);
323
+ }
324
+ }
325
+
326
+ _handleResize() {
327
+ if (!this.container) return;
328
+ const w = this.container.clientWidth || 1;
329
+ const h = this.container.clientHeight || 1;
330
+ this.camera.aspect = w / h;
331
+ this.camera.updateProjectionMatrix();
332
+ this.renderer.setSize(w, h, false);
333
+ }
334
+
335
+ // ============================================================
336
+ // Public API - mirrors live ReachyMini SDK so block-generated
337
+ // code can target either without conditional logic.
338
+ // ============================================================
339
+
340
+ setHeadRpyDeg(roll, pitch, yaw) {
341
+ this._tgt.head = {
342
+ roll: clamp(Number(roll) || 0, -40, 40),
343
+ pitch: clamp(Number(pitch) || 0, -40, 40),
344
+ yaw: clamp(Number(yaw) || 0, -180, 180),
345
+ };
346
+ }
347
+
348
+ setAntennasDeg(rightDeg, leftDeg) {
349
+ this._tgt.antennas = {
350
+ right: clamp(Number(rightDeg) || 0, -175, 175),
351
+ left: clamp(Number(leftDeg) || 0, -175, 175),
352
+ };
353
+ }
354
+
355
+ setBodyYawDeg(yawDeg) {
356
+ this._tgt.body_yaw = clamp(Number(yawDeg) || 0, -160, 160);
357
+ }
358
+
359
+ // SDK-compat: setTarget accepts {head: number[16], antennas: [r,l] in rad,
360
+ // body_yaw: rad}. We only support antennas + body_yaw for v1; head matrix
361
+ // would need full 4×4 → RPY conversion, which we'd add later.
362
+ setTarget({ head, antennas, body_yaw } = {}) {
363
+ if (Array.isArray(antennas) && antennas.length === 2) {
364
+ this.setAntennasDeg(rad(antennas[0]), rad(antennas[1]));
365
+ }
366
+ if (typeof body_yaw === 'number') {
367
+ this.setBodyYawDeg(rad(body_yaw));
368
+ }
369
+ // head matrix → RPY: TODO
370
+ }
371
+
372
+ async wakeUp() {
373
+ this._asleep = false;
374
+ this.setHeadRpyDeg(0, -8, 0);
375
+ this.setAntennasDeg(20, -20);
376
+ await this._wait(300);
377
+ this.setAntennasDeg(0, 0);
378
+ this.setHeadRpyDeg(0, 0, 0);
379
+ await this._wait(400);
380
+ }
381
+
382
+ async gotoSleep() {
383
+ this._asleep = true;
384
+ this.setHeadRpyDeg(0, 25, 0); // head dips forward
385
+ this.setAntennasDeg(-30, 30);
386
+ await this._wait(800);
387
+ }
388
+
389
+ isAwake() { return !this._asleep; }
390
+ ensureAwake() { return Promise.resolve(); }
391
+
392
+ playSound(file) {
393
+ // No speakers in sim - surface as an event so the UI can show a toast.
394
+ this.dispatchEvent(new CustomEvent('playSound', { detail: { file } }));
395
+ }
396
+ setMotorMode(mode) {
397
+ this.dispatchEvent(new CustomEvent('motorMode', { detail: { mode } }));
398
+ }
399
+ setVolume(v) { return Promise.resolve(v); }
400
+ getVersion() { return Promise.resolve('sim'); }
401
+
402
+ // Sensing accessors (read from CURRENT, smooth-lerped state)
403
+ getHeadRpy() { return { ...this._cur.head }; }
404
+ getBodyYaw() { return this._cur.body_yaw; }
405
+ getAntennas() { return { ...this._cur.antennas }; }
406
+ isMoving() { return this._isMoving; }
407
+
408
+ // ---- Convenience ----
409
+ resetView() {
410
+ this.camera.position.set(0.42, 0.32, 0.42);
411
+ this.controls.target.set(0, 0.18, 0);
412
+ this.controls.update();
413
+ }
414
+
415
+ _wait(ms) { return new Promise(r => setTimeout(r, ms)); }
416
+ }
417
+
418
+ function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }