Oliver Nitsche Claude Sonnet 4.6 commited on
Commit
771abdd
·
1 Parent(s): 4a11cd1

Initial scaffold: Talk with Claude via Reachy Mini

Browse files

Static HF Space — Claude API (streaming) + animated robot gestures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (3) hide show
  1. README.md +8 -5
  2. index.html +650 -17
  3. style.css +524 -18
README.md CHANGED
@@ -1,10 +1,13 @@
1
  ---
2
- title: Talk With Claude
3
- emoji: 🏆
4
- colorFrom: blue
5
  colorTo: indigo
6
  sdk: static
7
  pinned: false
 
 
 
 
 
8
  ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: Talk with Claude — Reachy Mini
3
+ emoji: 🤖
4
+ colorFrom: purple
5
  colorTo: indigo
6
  sdk: static
7
  pinned: false
8
+ hf_oauth: true
9
+ hf_oauth_expiration_minutes: 480
10
+ tags:
11
+ - reachy_mini
12
+ - reachy_mini_js_app
13
  ---
 
 
index.html CHANGED
@@ -1,19 +1,652 @@
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, maximum-scale=1, user-scalable=no" />
6
+ <title>Talk with Claude — Reachy Mini</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=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="style.css">
11
+ </head>
12
+ <body>
13
+
14
+ <!-- Login View -->
15
+ <div id="loginView" class="login-view">
16
+ <div class="login-card">
17
+ <img class="login-logo" src="https://raw.githubusercontent.com/pollen-robotics/reachy-mini-desktop-app/develop/src-tauri/icons/128x128.png" alt="Reachy Mini">
18
+ <h2>Talk with Claude</h2>
19
+ <p>Sign in with your HuggingFace account to connect your Reachy Mini and have an animated conversation powered by Claude AI.</p>
20
+ <button class="btn-hf" onclick="loginToHuggingFace()">
21
+ <svg width="18" height="18" viewBox="0 0 95 88" fill="currentColor">
22
+ <path d="M47.5 0C26.3 0 9.1 17.2 9.1 38.4v2.9c0 4.5 1.1 9 3.2 13L0 88h95L82.7 54.3c2.1-4 3.2-8.5 3.2-13v-2.9C85.9 17.2 68.7 0 47.5 0z"/>
23
+ </svg>
24
+ Sign in with Hugging Face
25
+ </button>
26
+ </div>
27
+ </div>
28
+
29
+ <!-- Main App -->
30
+ <div id="mainApp" class="hidden">
31
+ <header class="header">
32
+ <div class="logo">
33
+ <img src="https://raw.githubusercontent.com/pollen-robotics/reachy-mini-desktop-app/develop/src-tauri/icons/128x128.png" alt="Reachy Mini">
34
+ <div class="logo-text">Talk with Claude <span>via Reachy Mini</span></div>
35
+ </div>
36
+ <div class="user-section">
37
+ <div class="user-badge"><span id="username">@user</span></div>
38
+ <button class="btn-icon" onclick="openSettings()" title="Settings" aria-label="Settings">
39
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
40
+ <circle cx="12" cy="12" r="3"></circle>
41
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
42
+ </svg>
43
+ </button>
44
+ <button class="btn-logout" onclick="logout()">Sign out</button>
45
+ </div>
46
+ </header>
47
+
48
+ <div class="app-container">
49
+
50
+ <!-- Video -->
51
+ <div class="video-container">
52
+ <video id="remoteVideo" autoplay playsinline muted></video>
53
+ <div class="video-overlay-top">
54
+ <div class="connection-badge">
55
+ <div class="status-indicator" id="statusIndicator"></div>
56
+ <span id="statusText">Disconnected</span>
57
+ </div>
58
+ <div class="robot-name" id="robotName"></div>
59
+ <div class="latency-badge hidden" id="latencyBadge">
60
+ <span id="latencyValue">--</span>
61
+ </div>
62
+ </div>
63
+ <div class="video-overlay-bottom">
64
+ <div class="video-controls">
65
+ <button class="btn btn-secondary" id="connectBtn" onclick="connectSignaling()">Connect</button>
66
+ <button class="btn btn-primary" id="startBtn" onclick="startStream()" disabled>Start</button>
67
+ <button class="btn btn-danger" id="stopBtn" onclick="stopStream()" disabled>Stop</button>
68
+ <button class="btn btn-mute muted" id="muteBtn" onclick="toggleMute()" disabled>
69
+ <svg id="speakerOffIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
70
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
71
+ <line x1="23" y1="9" x2="17" y2="15"></line>
72
+ <line x1="17" y1="9" x2="23" y2="15"></line>
73
+ </svg>
74
+ <svg id="speakerOnIcon" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
75
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
76
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
77
+ </svg>
78
+ <span id="muteText">Unmute</span>
79
+ </button>
80
+ </div>
81
+ </div>
82
+ </div>
83
+
84
+ <!-- Robot Selector -->
85
+ <div id="robotSelector" class="panel hidden">
86
+ <div class="panel-header">Available Robots</div>
87
+ <div class="panel-content">
88
+ <div id="robotList" class="robot-list">
89
+ <div style="color: var(--text-muted); font-size: 0.85em;">Searching…</div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <!-- Conversation History -->
95
+ <div class="panel" id="conversationPanel">
96
+ <div class="panel-header">Conversation</div>
97
+ <div class="panel-content">
98
+ <div id="conversationHistory" class="conversation-history">
99
+ <div class="message msg-system">Connect your robot and press Talk to start.</div>
100
+ </div>
101
+ <div id="interimTranscript" class="interim-transcript"></div>
102
+ </div>
103
+ </div>
104
+
105
+ <!-- Talk Panel -->
106
+ <div class="panel" id="talkPanel">
107
+ <div class="panel-header">Voice</div>
108
+ <div class="panel-content talk-content">
109
+ <div id="convStateLabel" class="conv-state state-idle">Idle</div>
110
+ <button id="talkBtn" class="talk-btn" onclick="startListening()" disabled aria-label="Start talking">
111
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
112
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
113
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
114
+ <line x1="12" y1="19" x2="12" y2="23"></line>
115
+ <line x1="8" y1="23" x2="16" y2="23"></line>
116
+ </svg>
117
+ <span>Talk</span>
118
+ </button>
119
+ <button id="stopSpeakBtn" class="btn btn-danger hidden" onclick="stopSpeaking()">Stop</button>
120
+ <p id="sttUnsupported" class="error-text hidden">Speech recognition is not supported in this browser. Please use Chrome or Edge.</p>
121
+ </div>
122
+ </div>
123
+
124
+ </div>
125
+ </div>
126
+
127
+ <!-- Settings Modal -->
128
+ <div id="settingsModal" class="modal hidden" onclick="closeSettingsOnBackdrop(event)">
129
+ <div class="modal-card">
130
+ <h3>Settings</h3>
131
+ <label class="setting-label" for="apiKeyInput">Anthropic API Key</label>
132
+ <input type="password" id="apiKeyInput" class="setting-input" placeholder="sk-ant-…" autocomplete="off">
133
+ <label class="setting-label" for="modelSelect">Model</label>
134
+ <select id="modelSelect" class="setting-input">
135
+ <option value="claude-haiku-4-5-20251001">Claude Haiku 4.5 — Fast</option>
136
+ <option value="claude-sonnet-4-6">Claude Sonnet 4.6 — Smart</option>
137
+ </select>
138
+ <label class="setting-label" for="systemPromptInput">System Prompt <span class="optional">(optional)</span></label>
139
+ <textarea id="systemPromptInput" class="setting-textarea" rows="4"></textarea>
140
+ <div class="modal-buttons">
141
+ <button class="btn btn-primary" onclick="saveSettings()">Save</button>
142
+ <button class="btn btn-secondary" onclick="closeSettings()">Cancel</button>
143
+ </div>
144
+ </div>
145
+ </div>
146
+
147
+ <script type="module">
148
+ import { ReachyMini } from "https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@v1.7.1/js/reachy-mini.js";
149
+
150
+ const robot = new ReachyMini({ appName: "talk_with_claude" });
151
+
152
+ const DEFAULT_SYSTEM_PROMPT =
153
+ "You are Reachy Mini, a friendly and curious robot assistant by Pollen Robotics, powered by Claude AI. " +
154
+ "Keep responses concise (2–3 sentences), warm, and expressive. You love exploring ideas and engaging with humans.";
155
+
156
+ // ── Runtime state ─────────────────────────────────────────────
157
+ let selectedRobotId = null;
158
+ let detachVideo = null;
159
+ let latencyIntervalId = null;
160
+ let animIntervalId = null;
161
+ let idleBreathTimeoutId = null;
162
+ let recognition = null;
163
+ let convState = 'idle'; // idle | listening | thinking | speaking
164
+ const conversationHistory = [];
165
+
166
+ // ── Globals for onclick ───────────────────────────────────────
167
+ window.loginToHuggingFace = () => robot.login();
168
+ window.logout = logout;
169
+ window.connectSignaling = connectSignaling;
170
+ window.startStream = startStream;
171
+ window.stopStream = stopStream;
172
+ window.toggleMute = toggleMute;
173
+ window.startListening = startListening;
174
+ window.stopSpeaking = stopSpeaking;
175
+ window.openSettings = openSettings;
176
+ window.closeSettings = closeSettings;
177
+ window.closeSettingsOnBackdrop = closeSettingsOnBackdrop;
178
+ window.saveSettings = saveSettings;
179
+
180
+ // ── Init ──────────────────────────────────────────────────────
181
+ document.addEventListener('DOMContentLoaded', async () => {
182
+ document.getElementById('apiKeyInput').value =
183
+ localStorage.getItem('claude_api_key') || '';
184
+ document.getElementById('systemPromptInput').value =
185
+ localStorage.getItem('claude_system_prompt') || DEFAULT_SYSTEM_PROMPT;
186
+ const savedModel = localStorage.getItem('claude_model') || 'claude-haiku-4-5-20251001';
187
+ document.getElementById('modelSelect').value = savedModel;
188
+
189
+ if (await robot.authenticate()) {
190
+ showMainApp();
191
+ } else {
192
+ showLogin();
193
+ }
194
+ initRobotEvents();
195
+ initSpeechRecognition();
196
+ });
197
+
198
+ // ── Auth ──────────────────────────────────────────────────────
199
+ function logout() {
200
+ if (detachVideo) { detachVideo(); detachVideo = null; }
201
+ stopAnimLoop();
202
+ robot.logout();
203
+ showLogin();
204
+ }
205
+
206
+ function showLogin() {
207
+ document.getElementById('loginView').classList.remove('hidden');
208
+ document.getElementById('mainApp').classList.add('hidden');
209
+ }
210
+
211
+ function showMainApp() {
212
+ document.getElementById('loginView').classList.add('hidden');
213
+ document.getElementById('mainApp').classList.remove('hidden');
214
+ document.getElementById('username').textContent = '@' + robot.username;
215
+ }
216
+
217
+ // ── Settings ──────────────────────────────────────────────────
218
+ function openSettings() {
219
+ document.getElementById('settingsModal').classList.remove('hidden');
220
+ }
221
+ function closeSettings() {
222
+ document.getElementById('settingsModal').classList.add('hidden');
223
+ }
224
+ function closeSettingsOnBackdrop(e) {
225
+ if (e.target === document.getElementById('settingsModal')) closeSettings();
226
+ }
227
+ function saveSettings() {
228
+ localStorage.setItem('claude_api_key', document.getElementById('apiKeyInput').value.trim());
229
+ localStorage.setItem('claude_model', document.getElementById('modelSelect').value);
230
+ localStorage.setItem('claude_system_prompt', document.getElementById('systemPromptInput').value.trim());
231
+ closeSettings();
232
+ }
233
+
234
+ // ── Robot Events ──────────────────────────────────────────────
235
+ function initRobotEvents() {
236
+ robot.addEventListener('robotsChanged', (e) => displayRobots(e.detail.robots));
237
+
238
+ robot.addEventListener('streaming', async () => {
239
+ updateStatus('connected', 'Connected');
240
+ enableTalk(true);
241
+ document.getElementById('robotSelector').classList.add('hidden');
242
+ startLatencyDisplay();
243
+ try { await robot.ensureAwake(); } catch (_) {}
244
+ greetingAnimation();
245
+ });
246
+
247
+ robot.addEventListener('sessionStopped', () => {
248
+ document.getElementById('startBtn').disabled = !selectedRobotId;
249
+ document.getElementById('stopBtn').disabled = true;
250
+ document.getElementById('robotSelector').classList.remove('hidden');
251
+ enableTalk(false);
252
+ updateStatus('connected', 'Connected');
253
+ stopLatencyDisplay();
254
+ stopAnimLoop();
255
+ setConvState('idle');
256
+ });
257
+
258
+ robot.addEventListener('disconnected', () => {
259
+ updateStatus('', 'Disconnected');
260
+ document.getElementById('connectBtn').disabled = false;
261
+ document.getElementById('robotSelector').classList.add('hidden');
262
+ enableTalk(false);
263
+ stopAnimLoop();
264
+ });
265
+
266
+ robot.addEventListener('error', (e) => console.error(`[${e.detail.source}]`, e.detail.error));
267
+ }
268
+
269
+ // ── Connection ────────────────────────────────────────────────
270
+ function updateStatus(cls, text) {
271
+ document.getElementById('statusIndicator').className = 'status-indicator ' + cls;
272
+ document.getElementById('statusText').textContent = text;
273
+ }
274
+
275
+ async function connectSignaling() {
276
+ if (!robot.isAuthenticated) return;
277
+ updateStatus('connecting', 'Connecting…');
278
+ document.getElementById('connectBtn').disabled = true;
279
+ try {
280
+ await robot.connect();
281
+ updateStatus('connected', 'Connected');
282
+ document.getElementById('robotSelector').classList.remove('hidden');
283
+ } catch (e) {
284
+ console.error('Connection failed:', e);
285
+ updateStatus('', 'Disconnected');
286
+ document.getElementById('connectBtn').disabled = false;
287
+ }
288
+ }
289
+
290
+ function displayRobots(robots) {
291
+ const list = document.getElementById('robotList');
292
+ list.innerHTML = '';
293
+ if (!robots?.length) {
294
+ list.innerHTML = '<div style="color:var(--text-muted)">No robots online</div>';
295
+ document.getElementById('startBtn').disabled = true;
296
+ return;
297
+ }
298
+ for (const r of robots) {
299
+ const div = document.createElement('div');
300
+ div.className = 'robot-card' + (r.id === selectedRobotId ? ' selected' : '');
301
+ div.innerHTML = `<div class="name">${r.meta?.name || 'Reachy Mini'}</div><div class="id">${r.id.slice(0, 12)}…</div>`;
302
+ div.onclick = () => {
303
+ document.querySelectorAll('.robot-card').forEach(el => el.classList.remove('selected'));
304
+ div.classList.add('selected');
305
+ selectedRobotId = r.id;
306
+ document.getElementById('robotName').textContent = r.meta?.name || 'Reachy Mini';
307
+ document.getElementById('startBtn').disabled = false;
308
+ };
309
+ list.appendChild(div);
310
+ }
311
+ }
312
+
313
+ async function startStream() {
314
+ if (!selectedRobotId) return;
315
+ updateStatus('connecting', 'Starting…');
316
+ detachVideo = robot.attachVideo(document.getElementById('remoteVideo'));
317
+ document.getElementById('startBtn').disabled = true;
318
+ document.getElementById('stopBtn').disabled = false;
319
+ try {
320
+ await robot.startSession(selectedRobotId);
321
+ } catch (e) {
322
+ if (detachVideo) { detachVideo(); detachVideo = null; }
323
+ document.getElementById('startBtn').disabled = !selectedRobotId;
324
+ document.getElementById('stopBtn').disabled = true;
325
+ const msg = e.reason === 'robot_busy'
326
+ ? `Robot busy — "${e.activeApp}" is already connected`
327
+ : (e.message || 'Session failed');
328
+ updateStatus('disconnected', msg);
329
+ }
330
+ }
331
+
332
+ async function stopStream() {
333
+ if (detachVideo) { detachVideo(); detachVideo = null; }
334
+ await robot.stopSession();
335
+ }
336
+
337
+ // ── Audio ─────────────────────────────────────────────────────
338
+ function toggleMute() {
339
+ robot.setAudioMuted(!robot.audioMuted);
340
+ const muted = robot.audioMuted;
341
+ document.getElementById('muteBtn').classList.toggle('muted', muted);
342
+ document.getElementById('speakerOffIcon').classList.toggle('hidden', !muted);
343
+ document.getElementById('speakerOnIcon').classList.toggle('hidden', muted);
344
+ document.getElementById('muteText').textContent = muted ? 'Unmute' : 'Mute';
345
+ }
346
+
347
+ function enableTalk(on) {
348
+ document.getElementById('talkBtn').disabled = !on;
349
+ document.getElementById('muteBtn').disabled = !on;
350
+ }
351
+
352
+ // ── Latency ───────────────────────────────────────────────────
353
+ function startLatencyDisplay() {
354
+ const badge = document.getElementById('latencyBadge');
355
+ const label = document.getElementById('latencyValue');
356
+ badge.classList.remove('hidden');
357
+ latencyIntervalId = setInterval(() => {
358
+ const video = document.getElementById('remoteVideo');
359
+ let ms = null;
360
+ if (video?.buffered?.length > 0) {
361
+ ms = Math.round((video.buffered.end(video.buffered.length - 1) - video.currentTime) * 1000);
362
+ }
363
+ label.textContent = ms != null ? `buf ${ms}ms` : '--';
364
+ badge.classList.remove('good', 'ok', 'bad');
365
+ if (ms != null) badge.classList.add(ms < 200 ? 'good' : ms < 500 ? 'ok' : 'bad');
366
+ }, 1000);
367
+ }
368
+
369
+ function stopLatencyDisplay() {
370
+ if (latencyIntervalId) { clearInterval(latencyIntervalId); latencyIntervalId = null; }
371
+ document.getElementById('latencyBadge').classList.add('hidden');
372
+ }
373
+
374
+ // ── Conversation State Machine ────────────────────────────────
375
+ function setConvState(state) {
376
+ convState = state;
377
+ const labels = { idle: 'Idle', listening: 'Listening…', thinking: 'Thinking…', speaking: 'Speaking…' };
378
+ const el = document.getElementById('convStateLabel');
379
+ el.textContent = labels[state] || state;
380
+ el.className = `conv-state state-${state}`;
381
+
382
+ const isListening = state === 'listening';
383
+ const isSpeaking = state === 'speaking';
384
+ document.getElementById('talkBtn').classList.toggle('hidden', isListening || isSpeaking);
385
+ document.getElementById('stopSpeakBtn').classList.toggle('hidden', !isSpeaking);
386
+
387
+ if (state === 'idle') setIdleMotion();
388
+ else if (state === 'listening') setListeningMotion();
389
+ else if (state === 'thinking') setThinkingMotion();
390
+ else if (state === 'speaking') setSpeakingMotion();
391
+ }
392
+
393
+ // ── Speech Recognition ────────────────────────────────────────
394
+ function initSpeechRecognition() {
395
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
396
+ if (!SR) {
397
+ document.getElementById('sttUnsupported').classList.remove('hidden');
398
+ return;
399
+ }
400
+ recognition = new SR();
401
+ recognition.continuous = false;
402
+ recognition.interimResults = true;
403
+ recognition.lang = navigator.language || 'en-US';
404
+
405
+ recognition.onresult = (e) => {
406
+ const transcript = Array.from(e.results).map(r => r[0].transcript).join('');
407
+ document.getElementById('interimTranscript').textContent = transcript;
408
+ if (e.results[e.results.length - 1].isFinal) {
409
+ document.getElementById('interimTranscript').textContent = '';
410
+ if (transcript.trim()) sendToClaude(transcript.trim());
411
+ }
412
+ };
413
+
414
+ recognition.onerror = (e) => {
415
+ if (e.error === 'no-speech') { setConvState('idle'); return; }
416
+ console.error('STT error:', e.error);
417
+ addMessage('msg-system', `Mic error: ${e.error}`);
418
+ setConvState('idle');
419
+ };
420
+
421
+ recognition.onend = () => {
422
+ if (convState === 'listening') setConvState('idle');
423
+ };
424
+ }
425
+
426
+ function startListening() {
427
+ if (!recognition) return;
428
+ const apiKey = localStorage.getItem('claude_api_key');
429
+ if (!apiKey) {
430
+ openSettings();
431
+ addMessage('msg-system', 'Enter your Anthropic API key in Settings first.');
432
+ return;
433
+ }
434
+ speechSynthesis.cancel();
435
+ setConvState('listening');
436
+ try {
437
+ recognition.start();
438
+ } catch (_) {
439
+ setConvState('idle');
440
+ }
441
+ }
442
+
443
+ // ── Claude API (streaming) ─────────────────────────────────────
444
+ async function sendToClaude(userText) {
445
+ setConvState('thinking');
446
+ addMessage('msg-user', userText);
447
+ conversationHistory.push({ role: 'user', content: userText });
448
+
449
+ const apiKey = localStorage.getItem('claude_api_key') || '';
450
+ const systemPrompt = localStorage.getItem('claude_system_prompt') || DEFAULT_SYSTEM_PROMPT;
451
+ const model = localStorage.getItem('claude_model') || 'claude-haiku-4-5-20251001';
452
+
453
+ const assistantDiv = createStreamingMessage();
454
+
455
+ try {
456
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
457
+ method: 'POST',
458
+ headers: {
459
+ 'x-api-key': apiKey,
460
+ 'anthropic-version': '2023-06-01',
461
+ 'anthropic-dangerous-direct-browser-access': 'true',
462
+ 'content-type': 'application/json',
463
+ },
464
+ body: JSON.stringify({
465
+ model,
466
+ max_tokens: 512,
467
+ stream: true,
468
+ system: systemPrompt,
469
+ messages: conversationHistory.slice(-10),
470
+ }),
471
+ });
472
+
473
+ if (!res.ok) {
474
+ const err = await res.text();
475
+ throw new Error(`Claude ${res.status}: ${err}`);
476
+ }
477
+
478
+ const reader = res.body.getReader();
479
+ const decoder = new TextDecoder();
480
+ let fullReply = '';
481
+ let buffer = '';
482
+
483
+ while (true) {
484
+ const { done, value } = await reader.read();
485
+ if (done) break;
486
+ buffer += decoder.decode(value, { stream: true });
487
+ const lines = buffer.split('\n');
488
+ buffer = lines.pop(); // keep incomplete last line
489
+ for (const line of lines) {
490
+ if (!line.startsWith('data: ')) continue;
491
+ const data = line.slice(6).trim();
492
+ if (!data || data === '[DONE]') continue;
493
+ try {
494
+ const ev = JSON.parse(data);
495
+ if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
496
+ fullReply += ev.delta.text;
497
+ updateStreamingMessage(assistantDiv, fullReply);
498
+ }
499
+ } catch (_) {}
500
+ }
501
+ }
502
+
503
+ assistantDiv.classList.remove('streaming');
504
+ conversationHistory.push({ role: 'assistant', content: fullReply });
505
+ speakReply(fullReply);
506
+ } catch (e) {
507
+ console.error('Claude error:', e);
508
+ assistantDiv.remove();
509
+ addMessage('msg-system', `Error: ${e.message}`);
510
+ setConvState('idle');
511
+ }
512
+ }
513
+
514
+ // ── TTS ───────────────────────────────────────────────────────
515
+ function speakReply(text) {
516
+ setConvState('speaking');
517
+ const utt = new SpeechSynthesisUtterance(text);
518
+ utt.lang = navigator.language || 'en-US';
519
+ utt.rate = 1.05;
520
+ utt.onend = () => setConvState('idle');
521
+ utt.onerror = () => setConvState('idle');
522
+ speechSynthesis.speak(utt);
523
+ }
524
+
525
+ function stopSpeaking() {
526
+ speechSynthesis.cancel();
527
+ setConvState('idle');
528
+ }
529
+
530
+ // ── Chat UI ───────────────────────────────────────────────────
531
+ function addMessage(cls, text) {
532
+ const history = document.getElementById('conversationHistory');
533
+ const placeholder = history.querySelector('.msg-system');
534
+ if (placeholder && conversationHistory.length <= 1) placeholder.remove();
535
+ const div = document.createElement('div');
536
+ div.className = `message ${cls}`;
537
+ div.textContent = text;
538
+ history.appendChild(div);
539
+ history.scrollTop = history.scrollHeight;
540
+ return div;
541
+ }
542
+
543
+ function createStreamingMessage() {
544
+ const history = document.getElementById('conversationHistory');
545
+ const placeholder = history.querySelector('.msg-system');
546
+ if (placeholder && conversationHistory.length <= 1) placeholder.remove();
547
+ const div = document.createElement('div');
548
+ div.className = 'message msg-assistant streaming';
549
+ div.textContent = '';
550
+ history.appendChild(div);
551
+ history.scrollTop = history.scrollHeight;
552
+ return div;
553
+ }
554
+
555
+ function updateStreamingMessage(div, text) {
556
+ div.textContent = text;
557
+ const history = document.getElementById('conversationHistory');
558
+ history.scrollTop = history.scrollHeight;
559
+ }
560
+
561
+ // ── Robot Motion ──────────────────────────────────────────────
562
+ function stopAnimLoop() {
563
+ if (animIntervalId) { clearInterval(animIntervalId); animIntervalId = null; }
564
+ if (idleBreathTimeoutId) { clearTimeout(idleBreathTimeoutId); idleBreathTimeoutId = null; }
565
+ }
566
+
567
+ function isRobotStreaming() { return robot.state === 'streaming'; }
568
+
569
+ function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
570
+
571
+ // Greeting nod played once when a session starts
572
+ async function greetingAnimation() {
573
+ if (!isRobotStreaming()) return;
574
+ robot.setHeadRpyDeg(0, -18, 0);
575
+ robot.setAntennasDeg(100, 100);
576
+ await delay(500);
577
+ if (!isRobotStreaming()) return;
578
+ robot.setHeadRpyDeg(0, 12, 0);
579
+ robot.setAntennasDeg(10, 10);
580
+ await delay(350);
581
+ if (!isRobotStreaming()) return;
582
+ robot.setHeadRpyDeg(0, -5, 0);
583
+ robot.setAntennasDeg(55, 55);
584
+ await delay(300);
585
+ if (!isRobotStreaming()) return;
586
+ setIdleMotion();
587
+ }
588
+
589
+ // Idle: neutral pose, then gentle breathing sway
590
+ function setIdleMotion() {
591
+ stopAnimLoop();
592
+ if (!isRobotStreaming()) return;
593
+ robot.setHeadRpyDeg(0, 0, 0);
594
+ robot.setAntennasDeg(15, 15);
595
+ let t = 0;
596
+ idleBreathTimeoutId = setTimeout(() => {
597
+ idleBreathTimeoutId = null;
598
+ if (convState !== 'idle') return;
599
+ animIntervalId = setInterval(() => {
600
+ if (convState !== 'idle' || !isRobotStreaming()) { stopAnimLoop(); return; }
601
+ t += 0.025;
602
+ robot.setHeadRpyDeg(0, 1.5 * Math.sin(t), 0);
603
+ }, 100);
604
+ }, 1200);
605
+ }
606
+
607
+ // Listening: attentive forward lean, antennas up
608
+ function setListeningMotion() {
609
+ stopAnimLoop();
610
+ if (!isRobotStreaming()) return;
611
+ robot.setHeadRpyDeg(8, 10, 0);
612
+ robot.setAntennasDeg(120, 120);
613
+ }
614
+
615
+ // Thinking: head scans side to side, antennas wiggle asymmetrically
616
+ function setThinkingMotion() {
617
+ stopAnimLoop();
618
+ if (!isRobotStreaming()) return;
619
+ let t = 0;
620
+ animIntervalId = setInterval(() => {
621
+ if (!isRobotStreaming()) { stopAnimLoop(); return; }
622
+ t += 0.07;
623
+ const yaw = 18 * Math.sin(t * 0.5);
624
+ const pitch = 5 * Math.sin(t * 0.3);
625
+ robot.setHeadRpyDeg(0, pitch, yaw);
626
+ robot.setAntennasDeg(
627
+ 55 * Math.sin(t),
628
+ -55 * Math.sin(t + 1.0),
629
+ );
630
+ }, 100);
631
+ }
632
+
633
+ // Speaking: nodding head, swaying body, antennas bouncing expressively
634
+ function setSpeakingMotion() {
635
+ stopAnimLoop();
636
+ if (!isRobotStreaming()) return;
637
+ let t = 0;
638
+ animIntervalId = setInterval(() => {
639
+ if (!isRobotStreaming()) { stopAnimLoop(); return; }
640
+ t += 0.1;
641
+ const pitch = -7 * Math.abs(Math.sin(t * 1.3)) - 1;
642
+ const roll = 3 * Math.sin(t * 0.6);
643
+ const yaw = 6 * Math.sin(t * 0.35);
644
+ robot.setHeadRpyDeg(roll, pitch, yaw);
645
+ try { robot.setBodyYawDeg(-4 * Math.sin(t * 0.35)); } catch (_) {}
646
+ const wave = 28 * Math.sin(t * 1.6);
647
+ robot.setAntennasDeg(42 + wave, 42 - wave * 0.6);
648
+ }, 100);
649
+ }
650
+ </script>
651
+ </body>
652
  </html>
style.css CHANGED
@@ -1,28 +1,534 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  }
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
  }
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
  }
17
 
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
 
 
 
 
24
  }
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
 
 
 
 
 
28
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --claude-primary: #7C3AED;
3
+ --claude-light: #9D5BFF;
4
+ --claude-dark: #6025C4;
5
+ --pollen-dark: #0F0E17;
6
+ --pollen-darker: #09080F;
7
+ --pollen-card: #1A1630;
8
+ --pollen-card-light: #221E40;
9
+ --text-primary: #FFFFFF;
10
+ --text-secondary: #A0AEC0;
11
+ --text-muted: #718096;
12
+ --success: #48BB78;
13
+ --warning: #ECC94B;
14
+ --danger: #F56565;
15
+ --listening: #63B3ED;
16
+ --thinking: #B794F4;
17
+ --speaking: #68D391;
18
+ }
19
+
20
+ * { box-sizing: border-box; margin: 0; padding: 0; }
21
+
22
  body {
23
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
24
+ background: var(--pollen-darker);
25
+ color: var(--text-primary);
26
+ min-height: 100vh;
27
+ min-height: 100dvh;
28
+ overflow-x: hidden;
29
+ }
30
+
31
+ /* ── Header ─────────────────────────────────────────────────── */
32
+ .header {
33
+ background: rgba(0, 0, 0, 0.4);
34
+ backdrop-filter: blur(10px);
35
+ padding: 8px 16px;
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: space-between;
39
+ border-bottom: 1px solid rgba(124, 58, 237, 0.3);
40
+ position: sticky;
41
+ top: 0;
42
+ z-index: 10;
43
+ }
44
+
45
+ .logo {
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 10px;
49
+ }
50
+
51
+ .logo img {
52
+ width: 32px;
53
+ height: 32px;
54
+ border-radius: 6px;
55
+ }
56
+
57
+ .logo-text {
58
+ font-weight: 700;
59
+ font-size: 1em;
60
+ color: var(--claude-light);
61
+ }
62
+
63
+ .logo-text span {
64
+ color: var(--text-secondary);
65
+ font-weight: 400;
66
+ font-size: 0.85em;
67
+ }
68
+
69
+ .user-section {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 8px;
73
+ }
74
+
75
+ .user-badge {
76
+ background: var(--pollen-card);
77
+ padding: 4px 12px;
78
+ border-radius: 16px;
79
+ font-size: 0.8em;
80
+ }
81
+
82
+ .btn-logout {
83
+ background: transparent;
84
+ border: 1px solid var(--text-muted);
85
+ color: var(--text-secondary);
86
+ padding: 4px 12px;
87
+ border-radius: 12px;
88
+ cursor: pointer;
89
+ font-size: 0.75em;
90
+ }
91
+
92
+ .btn-icon {
93
+ background: transparent;
94
+ border: none;
95
+ color: var(--text-secondary);
96
+ cursor: pointer;
97
+ padding: 4px;
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ border-radius: 8px;
102
+ transition: color 0.2s;
103
+ min-width: 44px;
104
+ min-height: 44px;
105
+ }
106
+
107
+ .btn-icon:hover { color: var(--claude-light); }
108
+
109
+ .btn-icon svg {
110
+ width: 20px;
111
+ height: 20px;
112
+ }
113
+
114
+ /* ── Main Layout ─────────────────────────────────────────────── */
115
+ .app-container {
116
+ display: flex;
117
+ flex-direction: column;
118
+ padding: 8px;
119
+ gap: 8px;
120
+ max-width: 900px;
121
+ margin: 0 auto;
122
+ }
123
+
124
+ /* ── Video ───────────────────────────────────────────────────── */
125
+ .video-container {
126
+ position: relative;
127
+ background: #000;
128
+ border-radius: 12px;
129
+ overflow: hidden;
130
+ aspect-ratio: 16 / 9;
131
+ width: 100%;
132
+ }
133
+
134
+ video {
135
+ width: 100%;
136
+ height: 100%;
137
+ object-fit: cover;
138
+ background: linear-gradient(135deg, #09080F 0%, #1A1630 100%);
139
+ }
140
+
141
+ .video-overlay-top {
142
+ position: absolute;
143
+ top: 0; left: 0; right: 0;
144
+ padding: 12px;
145
+ background: linear-gradient(to bottom, rgba(0,0,0,0.7), transparent);
146
+ display: flex;
147
+ justify-content: space-between;
148
+ align-items: flex-start;
149
+ }
150
+
151
+ .connection-badge {
152
+ display: flex;
153
+ align-items: center;
154
+ gap: 6px;
155
+ background: rgba(0,0,0,0.5);
156
+ padding: 6px 12px;
157
+ border-radius: 16px;
158
+ font-size: 0.8em;
159
+ }
160
+
161
+ .latency-badge {
162
+ background: rgba(0,0,0,0.5);
163
+ padding: 4px 10px;
164
+ border-radius: 12px;
165
+ font-size: 0.75em;
166
+ font-variant-numeric: tabular-nums;
167
+ color: var(--text-secondary);
168
+ }
169
+
170
+ .latency-badge.good { color: var(--success); }
171
+ .latency-badge.ok { color: var(--warning); }
172
+ .latency-badge.bad { color: var(--danger); }
173
+
174
+ .status-indicator {
175
+ width: 8px;
176
+ height: 8px;
177
+ border-radius: 50%;
178
+ background: var(--danger);
179
+ }
180
+
181
+ .status-indicator.connected { background: var(--success); box-shadow: 0 0 8px var(--success); }
182
+ .status-indicator.connecting { background: var(--warning); animation: blink 0.8s infinite; }
183
+
184
+ @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
185
+
186
+ .robot-name {
187
+ background: rgba(0,0,0,0.5);
188
+ padding: 6px 12px;
189
+ border-radius: 16px;
190
+ font-size: 0.8em;
191
+ font-weight: 500;
192
+ }
193
+
194
+ .video-overlay-bottom {
195
+ position: absolute;
196
+ bottom: 0; left: 0; right: 0;
197
+ padding: 12px;
198
+ background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
199
+ }
200
+
201
+ .video-controls {
202
+ display: flex;
203
+ justify-content: center;
204
+ gap: 8px;
205
+ flex-wrap: wrap;
206
+ }
207
+
208
+ /* ── Buttons ─────────────────────────────────────────────────── */
209
+ .btn {
210
+ padding: 8px 16px;
211
+ border: none;
212
+ border-radius: 8px;
213
+ font-weight: 600;
214
+ font-size: 0.85em;
215
+ cursor: pointer;
216
+ transition: all 0.2s;
217
+ min-height: 44px;
218
+ }
219
+
220
+ .btn-primary { background: var(--claude-primary); color: white; }
221
+ .btn-secondary { background: rgba(255,255,255,0.15); color: white; }
222
+ .btn-danger { background: var(--danger); color: white; }
223
+
224
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
225
+
226
+ .btn-mute {
227
+ background: rgba(255,255,255,0.15);
228
+ color: white;
229
+ display: flex;
230
+ align-items: center;
231
+ gap: 6px;
232
+ }
233
+
234
+ .btn-mute.muted { background: var(--danger); }
235
+ .btn-mute svg { width: 16px; height: 16px; }
236
+
237
+ /* ── Panels ──────────────────────────────────────────────────── */
238
+ .panel {
239
+ background: var(--pollen-card);
240
+ border-radius: 12px;
241
+ overflow: hidden;
242
+ }
243
+
244
+ .panel-header {
245
+ padding: 10px 14px;
246
+ background: rgba(0,0,0,0.2);
247
+ font-weight: 600;
248
+ font-size: 0.85em;
249
+ color: var(--claude-light);
250
+ }
251
+
252
+ .panel-content { padding: 12px; }
253
+
254
+ /* ── Robot Selector ──────────────────────────────────────────── */
255
+ .robot-list { display: flex; flex-direction: column; gap: 8px; }
256
+
257
+ .robot-card {
258
+ padding: 10px 14px;
259
+ background: var(--pollen-darker);
260
+ border: 2px solid transparent;
261
+ border-radius: 8px;
262
+ cursor: pointer;
263
+ }
264
+
265
+ .robot-card:hover { background: var(--pollen-card-light); }
266
+ .robot-card.selected { border-color: var(--claude-primary); }
267
+ .robot-card .name { font-weight: 600; font-size: 0.9em; }
268
+ .robot-card .id { font-size: 0.75em; color: var(--text-muted); font-family: monospace; }
269
+
270
+ /* ── Conversation History ────────────────────────────────────── */
271
+ .conversation-history {
272
+ display: flex;
273
+ flex-direction: column;
274
+ gap: 8px;
275
+ max-height: 240px;
276
+ overflow-y: auto;
277
+ padding-bottom: 4px;
278
+ }
279
+
280
+ .message {
281
+ padding: 8px 12px;
282
+ border-radius: 10px;
283
+ font-size: 0.88em;
284
+ line-height: 1.4;
285
+ max-width: 90%;
286
+ word-break: break-word;
287
+ }
288
+
289
+ .msg-user {
290
+ background: var(--claude-dark);
291
+ color: white;
292
+ align-self: flex-end;
293
+ border-bottom-right-radius: 3px;
294
+ }
295
+
296
+ .msg-assistant {
297
+ background: var(--pollen-card-light);
298
+ color: var(--text-primary);
299
+ align-self: flex-start;
300
+ border-bottom-left-radius: 3px;
301
+ }
302
+
303
+ .msg-assistant.streaming::after {
304
+ content: '▌';
305
+ animation: blink-cursor 0.6s step-end infinite;
306
+ color: var(--claude-light);
307
+ margin-left: 1px;
308
+ }
309
+
310
+ @keyframes blink-cursor { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
311
+
312
+ .msg-system {
313
+ color: var(--text-muted);
314
+ font-size: 0.82em;
315
+ text-align: center;
316
+ align-self: center;
317
+ font-style: italic;
318
+ }
319
+
320
+ .interim-transcript {
321
+ color: var(--text-muted);
322
+ font-size: 0.85em;
323
+ font-style: italic;
324
+ min-height: 1.2em;
325
+ margin-top: 6px;
326
+ padding: 0 2px;
327
+ }
328
+
329
+ /* ── Talk Panel ──────────────────────────────────────────────── */
330
+ .talk-content {
331
+ display: flex;
332
+ flex-direction: column;
333
+ align-items: center;
334
+ gap: 12px;
335
+ padding: 20px 12px;
336
+ }
337
+
338
+ .conv-state {
339
+ font-size: 0.9em;
340
+ font-weight: 600;
341
+ letter-spacing: 0.03em;
342
+ transition: color 0.3s;
343
+ }
344
+
345
+ .state-idle { color: var(--text-muted); }
346
+ .state-listening { color: var(--listening); }
347
+ .state-thinking { color: var(--thinking); }
348
+ .state-speaking { color: var(--speaking); }
349
+
350
+ .talk-btn {
351
+ width: 88px;
352
+ height: 88px;
353
+ border-radius: 50%;
354
+ border: 3px solid var(--claude-primary);
355
+ background: transparent;
356
+ color: var(--claude-light);
357
+ cursor: pointer;
358
+ display: flex;
359
+ flex-direction: column;
360
+ align-items: center;
361
+ justify-content: center;
362
+ gap: 4px;
363
+ font-size: 0.8em;
364
+ font-weight: 600;
365
+ transition: all 0.2s;
366
+ }
367
+
368
+ .talk-btn:hover:not(:disabled) {
369
+ background: rgba(124, 58, 237, 0.15);
370
+ transform: scale(1.05);
371
+ }
372
+
373
+ .talk-btn:disabled {
374
+ opacity: 0.35;
375
+ cursor: not-allowed;
376
+ border-color: var(--text-muted);
377
+ color: var(--text-muted);
378
  }
379
 
380
+ .talk-btn svg {
381
+ width: 32px;
382
+ height: 32px;
383
  }
384
 
385
+ .error-text {
386
+ color: var(--danger);
387
+ font-size: 0.82em;
388
+ text-align: center;
 
389
  }
390
 
391
+ /* ── Modal ───────────────────────────────────────────────────── */
392
+ .modal {
393
+ position: fixed;
394
+ inset: 0;
395
+ background: rgba(0,0,0,0.7);
396
+ display: flex;
397
+ align-items: center;
398
+ justify-content: center;
399
+ padding: 16px;
400
+ z-index: 100;
401
  }
402
 
403
+ .modal-card {
404
+ background: var(--pollen-card);
405
+ border-radius: 16px;
406
+ padding: 24px;
407
+ width: 100%;
408
+ max-width: 420px;
409
+ display: flex;
410
+ flex-direction: column;
411
+ gap: 12px;
412
  }
413
+
414
+ .modal-card h3 {
415
+ color: var(--claude-light);
416
+ font-size: 1.1em;
417
+ margin-bottom: 4px;
418
+ }
419
+
420
+ .setting-label {
421
+ font-size: 0.85em;
422
+ font-weight: 600;
423
+ color: var(--text-secondary);
424
+ }
425
+
426
+ .optional {
427
+ font-weight: 400;
428
+ color: var(--text-muted);
429
+ font-size: 0.9em;
430
+ }
431
+
432
+ .setting-input,
433
+ .setting-textarea,
434
+ .setting-select {
435
+ width: 100%;
436
+ padding: 10px 12px;
437
+ background: var(--pollen-darker);
438
+ border: 1px solid var(--pollen-card-light);
439
+ border-radius: 8px;
440
+ color: var(--text-primary);
441
+ font-size: 0.85em;
442
+ font-family: inherit;
443
+ resize: vertical;
444
+ }
445
+
446
+ .setting-input:focus,
447
+ .setting-textarea:focus,
448
+ .setting-select:focus {
449
+ outline: none;
450
+ border-color: var(--claude-primary);
451
+ }
452
+
453
+ .modal-buttons {
454
+ display: flex;
455
+ gap: 8px;
456
+ justify-content: flex-end;
457
+ margin-top: 4px;
458
+ }
459
+
460
+ /* ── Login View ──────────────────────────────────────────────── */
461
+ .login-view {
462
+ min-height: 100vh;
463
+ min-height: 100dvh;
464
+ display: flex;
465
+ align-items: center;
466
+ justify-content: center;
467
+ padding: 20px;
468
+ }
469
+
470
+ .login-card {
471
+ background: var(--pollen-card);
472
+ padding: 40px;
473
+ border-radius: 16px;
474
+ text-align: center;
475
+ max-width: 380px;
476
+ border: 1px solid rgba(124, 58, 237, 0.2);
477
+ }
478
+
479
+ .login-logo {
480
+ width: 72px;
481
+ height: 72px;
482
+ margin-bottom: 20px;
483
+ border-radius: 12px;
484
+ }
485
+
486
+ .login-card h2 {
487
+ color: var(--claude-light);
488
+ margin-bottom: 10px;
489
+ font-size: 1.5em;
490
+ }
491
+
492
+ .login-card p {
493
+ color: var(--text-secondary);
494
+ margin-bottom: 24px;
495
+ font-size: 0.9em;
496
+ line-height: 1.5;
497
+ }
498
+
499
+ .btn-hf {
500
+ background: #FFD21E;
501
+ color: #000;
502
+ border: none;
503
+ padding: 12px 28px;
504
+ border-radius: 8px;
505
+ font-size: 0.95em;
506
+ font-weight: 700;
507
+ cursor: pointer;
508
+ display: inline-flex;
509
+ align-items: center;
510
+ gap: 8px;
511
+ min-height: 44px;
512
+ }
513
+
514
+ /* ── Desktop Layout ──────────────────────────────────────────── */
515
+ @media (min-width: 768px) {
516
+ .app-container {
517
+ display: grid;
518
+ grid-template-columns: 1fr 1fr;
519
+ grid-template-areas:
520
+ "video conversation"
521
+ "robots talk";
522
+ align-items: start;
523
+ gap: 12px;
524
+ }
525
+
526
+ .video-container { grid-area: video; }
527
+ #robotSelector { grid-area: robots; }
528
+ #conversationPanel { grid-area: conversation; }
529
+ #talkPanel { grid-area: talk; }
530
+
531
+ .conversation-history { max-height: 320px; }
532
+ }
533
+
534
+ .hidden { display: none !important; }