.env.example DELETED
@@ -1,58 +0,0 @@
1
- # Reachy Mini Website server env vars
2
- #
3
- # Copy this file to `.env` and fill in the values for local dev.
4
- # In production (HF Space), set these from the Space's "Settings →
5
- # Variables and secrets" panel, NOT from a committed `.env`.
6
- # (`.env` is gitignored.)
7
-
8
- # -----------------------------------------------------------------------------
9
- # Server
10
- # -----------------------------------------------------------------------------
11
- # Port the Express server listens on. Defaults to 7860 (HF Space convention).
12
- # PORT=7860
13
-
14
- # -----------------------------------------------------------------------------
15
- # OAuth (used by /api/oauth-config and the in-iframe sign-in flow)
16
- # -----------------------------------------------------------------------------
17
- # Set in the Space when `hf_oauth: true` is in README.md.
18
- # OAUTH_CLIENT_ID=
19
- # OAUTH_SCOPES=openid profile
20
-
21
- # -----------------------------------------------------------------------------
22
- # HF Inference Providers (used by /api/js-apps category inference)
23
- # -----------------------------------------------------------------------------
24
- # Required for category inference. A standard READ token is enough -
25
- # Inference Providers access is on by default for FREE/PRO tokens.
26
- # Without this, /api/js-apps still works but every entry will have
27
- # `categories: null` (the route logs a warning at startup).
28
- HF_TOKEN=
29
-
30
- # Dataset where the inferred-categories cache is persisted.
31
- # Defaults to `tfrere/reachy-mini-app-categories` (per-user namespace,
32
- # auto-created on first commit). Override to e.g.
33
- # `pollen-robotics/reachy-mini-app-categories` once the org dataset
34
- # exists and the HF_TOKEN has write access to it.
35
- # HF_CATEGORIES_DATASET=tfrere/reachy-mini-app-categories
36
-
37
- # -----------------------------------------------------------------------------
38
- # OpenAI Realtime ephemeral keys (used by /api/openai/ephemeral)
39
- # -----------------------------------------------------------------------------
40
- # Master OpenAI API key. Used SERVER-SIDE only to mint short-lived
41
- # (~1 minute) `client_secret.value` tokens for the Reachy Mini mobile
42
- # shell's voice conversation feature. NEVER expose this to a client
43
- # bundle. The mobile client never sees this value: it posts its HF
44
- # Bearer token, gets back an ephemeral session key scoped to a single
45
- # OpenAI Realtime session.
46
- OPENAI_API_KEY=
47
-
48
- # Optional: pin the Realtime model id. Defaults to
49
- # `gpt-4o-realtime-preview-2024-12-17`. Bumping this requires
50
- # coordinating with the mobile shell, which is built against a
51
- # specific protocol version.
52
- # OPENAI_REALTIME_MODEL=gpt-4o-realtime-preview-2024-12-17
53
-
54
- # Optional: max ephemeral mints per HF user per rolling hour.
55
- # Defaults to 60. The in-memory sliding-window limiter resets on
56
- # Space restart; v1 takes the trade-off, replace with a shared KV
57
- # the day we need multi-replica fairness.
58
- # OPENAI_EPHEMERAL_RATE_LIMIT_PER_HOUR=60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore CHANGED
@@ -22,5 +22,3 @@ dist-ssr
22
  *.njsproj
23
  *.sln
24
  *.sw?
25
-
26
- .env
 
22
  *.njsproj
23
  *.sln
24
  *.sw?
 
 
docs/APP_ICON_CONVENTION.md DELETED
@@ -1,113 +0,0 @@
1
- # App icon convention
2
-
3
- > Status: convention v1
4
- > Audience: authors shipping a Reachy Mini app to the Hugging Face Hub
5
- > Implemented by: `reachy-mini-website` catalog server (this repo) +
6
- > `reachy_mini_mobile_app`, `reachy_mini_desktop_app`
7
- > Source of truth: `server/index.js` → `findIconUrl()`
8
-
9
- This document specifies how a Reachy Mini app declares a custom icon.
10
- Apps that don't follow it keep working - the surface falls back to the
11
- front-matter `emoji:` glyph, which is the existing behaviour.
12
-
13
- ---
14
-
15
- ## 1. The convention in three lines
16
-
17
- To ship a custom icon for your Reachy Mini app:
18
-
19
- 1. Commit `icon.svg` (preferred) **or** `icon.png` at the root of your
20
- Hugging Face Space repository.
21
- 2. That's it. Within ~5 minutes (the catalog cache TTL) the mobile
22
- shell, the desktop app and the website surface your icon
23
- automatically, replacing the README front-matter emoji.
24
- 3. If both files are present, `icon.svg` wins.
25
-
26
- No README change required. No tag to add. No PR to file against this
27
- repo. The catalog server scans the file list once per refresh and
28
- publishes a resolved URL on the app entry; every client consumes it.
29
-
30
- ---
31
-
32
- ## 2. Why a file convention and not `cardData.thumbnail`
33
-
34
- HF Spaces support a `thumbnail:` field in README front-matter, but:
35
-
36
- - `thumbnail` is full-bleed marketing artwork (typically 1200x630),
37
- not a square avatar. Scaling it to a 22 px or 44 px tile produces
38
- muddy thumbnails.
39
- - We want app authors to ship a dedicated, optimised glyph they
40
- control without learning the HF metadata schema.
41
- - SVG support means the icon scales cleanly across every mount point
42
- (rail tile, pinned grid, iframe header) from a single asset.
43
-
44
- `thumbnail:` keeps its existing role (banner artwork on the Space's
45
- HF page) and is not consulted by this resolution path.
46
-
47
- ---
48
-
49
- ## 3. Format & dimension recommendations
50
-
51
- | Property | Recommended | Hard requirement |
52
- |----------|-------------|------------------|
53
- | Format | `icon.svg` (vector) | `icon.svg` or `icon.png` |
54
- | Aspect ratio | 1:1 (square) | Renderers crop with `object-fit: contain`, but non-square icons render with letterboxing - prefer a true square |
55
- | Min PNG size | 256x256 | None enforced. PNGs below 64x64 will look soft on the pinned grid (44 px on retina ≈ 88 effective px) |
56
- | Background | Transparent OR solid colour | None - your call. Renderers don't add their own plate, so an icon with no background renders directly on the tile colour |
57
- | Padding | Bake ~10% inner padding into the asset | None - but icons that bleed edge-to-edge will touch the tile's rounded corners |
58
- | Light/dark variants | Single asset that works on both | None - if you must, ship two SVGs and use `prefers-color-scheme` inside the SVG via CSS |
59
-
60
- ### Style notes
61
-
62
- - **Iconic, not photographic.** A solid filled silhouette reads at
63
- 22 px; a screenshot doesn't.
64
- - **High contrast against `background.paper`.** The mobile app paints
65
- the tile background with the surface colour (very light grey on
66
- light, near-black on dark). A pure white icon disappears on light.
67
- - **No drop shadow** baked into the asset. The renderer doesn't add
68
- one either, and a baked shadow won't scale across sizes.
69
-
70
- ---
71
-
72
- ## 4. How resolution works (for the curious)
73
-
74
- 1. The catalog server calls
75
- `https://huggingface.co/api/spaces?filter=reachy_mini&full=true`.
76
- With `full=true`, the HF Hub returns `siblings: [{ rfilename: ... }]`
77
- for every Space - the complete file list.
78
- 2. For each app, `findIconUrl()` (in `server/index.js`) scans the
79
- list for root-level filenames matching `ICON_CANDIDATES` in order
80
- (`icon.svg` → `icon.png`).
81
- 3. The first match becomes:
82
-
83
- ```
84
- https://huggingface.co/spaces/<author>/<repo>/resolve/main/<filename>
85
- ```
86
-
87
- `resolve/main/` (not `raw/main/`) so LFS pointers follow through
88
- transparently and the `Content-Type` is set from the extension,
89
- which `<img>` needs.
90
- 4. The URL is published on the app entry as a top-level `iconUrl`
91
- field. `null` when neither candidate exists.
92
- 5. Clients (`reachy_mini_mobile_app`, `reachy_mini_desktop_app`) read
93
- `iconUrl` and render an `<img>` when present, falling back to the
94
- front-matter emoji otherwise. A runtime image load failure
95
- re-falls-back to the emoji without a refresh.
96
-
97
- The whole resolution path is server-side, behind the 5-minute catalog
98
- cache. Adding 100 more apps adds zero per-client probes.
99
-
100
- ---
101
-
102
- ## 5. Adding new icon formats
103
-
104
- If you need to support a new format (say, `icon.webp`), edit
105
- `ICON_CANDIDATES` in `server/index.js`:
106
-
107
- ```js
108
- const ICON_CANDIDATES = ['icon.svg', 'icon.png', 'icon.webp'];
109
- ```
110
-
111
- Order matters - the first hit wins, so put the preferred format first.
112
- Bumping the catalog cache (POST `/api/js-apps/refresh-categories` or
113
- just wait 5 minutes) picks up the new resolution rule.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
package.json CHANGED
@@ -18,7 +18,6 @@
18
  "@mui/icons-material": "^7.3.6",
19
  "@mui/material": "^7.3.6",
20
  "@react-spring/web": "^10.0.3",
21
- "compression": "^1.8.1",
22
  "express": "^4.21.2",
23
  "framer-motion": "^12.23.26",
24
  "fuse.js": "^7.1.0",
 
18
  "@mui/icons-material": "^7.3.6",
19
  "@mui/material": "^7.3.6",
20
  "@react-spring/web": "^10.0.3",
 
21
  "express": "^4.21.2",
22
  "framer-motion": "^12.23.26",
23
  "fuse.js": "^7.1.0",
scripts/evaluate-prompt-v2.py DELETED
@@ -1,445 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Prompt-v2 evaluation harness.
4
-
5
- Re-runs the LLM categorization on every JS app currently served by
6
- /api/js-apps with a tightened prompt, and prints a side-by-side
7
- diff against the live (v1) classifications.
8
-
9
- This file lives outside the server runtime - it never gets pushed
10
- to the Space. It's only meant to be hand-iterated until the diff
11
- looks right, then the chosen prompt is ported into server/categorize.js
12
- and server/categories.js.
13
-
14
- Run:
15
- python3 scripts/evaluate-prompt-v2.py
16
- """
17
- from __future__ import annotations
18
-
19
- import json
20
- import os
21
- import re
22
- import ssl
23
- import sys
24
- import time
25
- import urllib.error
26
- import urllib.request
27
- from pathlib import Path
28
- from typing import Any
29
-
30
- # Python 3.14 on macOS ships without the system CA bundle wired into
31
- # urllib by default - HF endpoints fail with CERTIFICATE_VERIFY_FAILED.
32
- # This script is dev-local only and only talks to huggingface.co, so
33
- # bypassing verification here is acceptable (would NEVER do this in
34
- # the server runtime).
35
- _SSL_CTX = ssl._create_unverified_context() # noqa: S323
36
-
37
- HF_INFERENCE_URL = "https://router.huggingface.co/v1/chat/completions"
38
- MODEL = "meta-llama/Llama-3.1-8B-Instruct"
39
- TEMPERATURE = 0
40
- MAX_TOKENS = 120
41
-
42
- README_MAX_CHARS = 3000
43
- MAX_CATEGORIES_PER_APP = 3
44
-
45
- JS_APPS_URL = "https://pollen-robotics-reachy-mini.hf.space/api/js-apps"
46
-
47
-
48
- # ──────────────────────────────────────────────────────────────────────
49
- # Taxonomy v2 - 9 slugs (added "games")
50
- # ──────────────────────────────────────────────────────────────────────
51
-
52
- CATEGORIES_V2: list[tuple[str, str]] = [
53
- (
54
- "music",
55
- "Music creation, playback, beats, songs, DJ mixing, instruments, "
56
- "blind-test music games. Requires actual music (rhythm/melody/song). "
57
- "NOT arbitrary audio (Morse code, alarms, TTS, sound effects).",
58
- ),
59
- (
60
- "dance",
61
- "Dance choreographies, motion replay, kinetic shows, "
62
- "recording/replaying robot movements, dance parties.",
63
- ),
64
- (
65
- "voice",
66
- "Reachy talks, listens, or holds a real-time voice conversation: "
67
- "TTS players, LLM-driven chat (OpenAI Realtime, Claude, Perplexity), "
68
- "wake-word demos, daily reports/news/weather read aloud.",
69
- ),
70
- (
71
- "storytelling",
72
- "Narrative stories WITH plot and characters: interactive fiction, "
73
- "bedtime tales, audio adventures, choose-your-own-adventure. "
74
- "NOT for daily reports, news, weather, or Q&A (use `voice`).",
75
- ),
76
- (
77
- "kids",
78
- "Apps that EXPLICITLY target children: the words kids / children / "
79
- "'for curious minds' / bedtime / 'learning for kids' must appear in "
80
- "the name or description, OR the app must be obviously kid-targeted. "
81
- "Combines with `storytelling`, `voice`, or `games`. Lifestyle, "
82
- "sports, weather, general conversation are NOT kids.",
83
- ),
84
- (
85
- "games",
86
- "Apps with a play loop: scores, rounds, win/lose conditions, "
87
- "quizzes, puzzles, sports simulations, dice/oracles (magic 8-ball), "
88
- "arcade-style mini-games.",
89
- ),
90
- (
91
- "vision",
92
- "Apps where Reachy's camera DRIVES behaviour: face/hand/pose "
93
- "tracking, image classification, gesture detection, visual mimicry. "
94
- "NOT for apps that merely stream or display the camera feed.",
95
- ),
96
- (
97
- "companion",
98
- "Apps with an EXPLICIT emotional/personality/buddy framing in the "
99
- "name or description (words like companion, buddy, mood, emotional, "
100
- "personality, pet, Tamagotchi). Being friendly is not enough.",
101
- ),
102
- (
103
- "dev-tools",
104
- "RESERVED slug — see DECISION ALGORITHM step 1 below. Use ONLY "
105
- "for pure technical artefacts (debug utilities, SDK probes, "
106
- "minimal protocol demos, dev-only test spaces) with no end-user "
107
- "experience. When used, it is the SOLE category — never combined.",
108
- ),
109
- ]
110
-
111
- ALLOWED = {slug for slug, _ in CATEGORIES_V2}
112
-
113
-
114
- # ──────────────────────────────────────────────────────────────────────
115
- # Few-shot examples - cover the main pitfalls of v1
116
- # ──────────────────────────────────────────────────────────────────────
117
-
118
- FEW_SHOT = [
119
- (
120
- "Reachy Morse",
121
- "Send Morse code through Reachy's speaker.",
122
- ["dev-tools"],
123
- "(STEP 1 veto: pure technical artefact. NOT music.)",
124
- ),
125
- (
126
- "WebRTC Demo",
127
- "Minimal WebRTC connection between Reachy and the browser.",
128
- ["dev-tools"],
129
- "(STEP 1 veto: protocol demo. NOT vision.)",
130
- ),
131
- (
132
- "TTS Reachy Mini",
133
- "Browser TTS that plays out of Reachy Mini's speaker.",
134
- ["voice"],
135
- "(USER-FACING speech output is voice, NOT dev-tools.)",
136
- ),
137
- (
138
- "Reachy Mochi - Emotional Companion",
139
- "Your pocket buddy that develops a mood and personality over time.",
140
- ["companion"],
141
- "(explicit emotional/companion framing)",
142
- ),
143
- (
144
- "Reachy Alive",
145
- "(README empty; name suggests autonomy and life-like presence)",
146
- ["companion"],
147
- "(USE THE NAME when the README is empty; 'alive' = companion-like)",
148
- ),
149
- (
150
- "Daily Surf Report",
151
- "Reachy reads today's surf report out loud.",
152
- ["voice"],
153
- "(NOT storytelling — a report has no narrative arc. "
154
- "NOT kids — surfing/sports are not kid-targeted.)",
155
- ),
156
- (
157
- "Music Quiz",
158
- "Play a blind test music game with a dancing Reachy.",
159
- ["music", "games", "dance"],
160
- "(multi-label: three slugs truly co-apply, ordered by relevance)",
161
- ),
162
- (
163
- "Mime Bot",
164
- "Reachy mimics your face live from your webcam.",
165
- ["vision"],
166
- "(NOT companion — mimicry is visual, no emotional framing.)",
167
- ),
168
- ]
169
-
170
-
171
- def build_system_prompt() -> str:
172
- taxonomy = "\n".join(f"- {slug}: {desc}" for slug, desc in CATEGORIES_V2)
173
- examples = "\n".join(
174
- f" - {name!r}: {desc!r}\n"
175
- f" → {{\"categories\": {json.dumps(cats)}}} {hint}"
176
- for name, desc, cats, hint in FEW_SHOT
177
- )
178
- return f"""You classify a Reachy Mini robot app into a CLOSED list of categories.
179
-
180
- OUTPUT FORMAT
181
- Return ONLY a single JSON object: {{"categories": ["slug1", "slug2"]}}.
182
- Pick 1 to {MAX_CATEGORIES_PER_APP} slugs, ordered from most to least relevant.
183
- Use the EXACT slug. No prose, no code fences, no commentary outside the JSON.
184
-
185
- DECISION ALGORITHM (apply in order)
186
-
187
- STEP 1 — `dev-tools` veto
188
- Is this app a PURE technical artefact with no user-facing experience
189
- beyond "here is how the SDK / API works"?
190
- Examples that pass the veto: WebRTC demo, SDK probe, debug utility,
191
- raw remote-control interface, dev-only test space.
192
- Examples that DO NOT pass the veto (they are user-facing apps):
193
- TTS players, voice chat, music apps, storytelling, companions —
194
- even when the README is dev-heavy.
195
- ─ YES → return {{"categories": ["dev-tools"]}} and STOP. Never combine.
196
- ─ NO → continue to STEP 2.
197
-
198
- STEP 2 — Pick 1 to {MAX_CATEGORIES_PER_APP} user-facing slugs from the
199
- list below. Choose the MOST SPECIFIC categories. Order from most to
200
- least relevant. Multi-label is encouraged when two categories truly
201
- co-apply (e.g. music-and-dance, kids storytelling, vision game).
202
- If the README is empty or very sparse, USE THE NAME AND DESCRIPTION
203
- as the primary signal — do not bail to an empty list just because the
204
- README is thin.
205
-
206
- STEP 3 — Strict slug rules (each must hold, or DO NOT use the slug)
207
- - `companion`: requires EXPLICIT emotional / personality / buddy framing
208
- (companion, buddy, friend, mood, emotional, personality, pet,
209
- Tamagotchi-like, "alive", "life companion"). Being friendly is not
210
- enough.
211
- - `music`: requires actual music — rhythm, melody, songs, beats, DJ
212
- sets, instruments, music quizzes. Arbitrary audio (Morse, alarms,
213
- TTS, sound effects) is NOT music.
214
- - `vision`: requires the camera to DRIVE behaviour (tracking,
215
- classification, mimicry). Merely streaming or displaying the camera
216
- (WebRTC demos, remote-control viewers) is NOT vision.
217
- - `storytelling`: requires a narrative ARC — plot, characters, scenes.
218
- Daily reports, news, weather, Q&A are NOT storytelling (they are
219
- `voice`).
220
- - `games`: requires a play loop — score, rounds, win/lose, puzzles,
221
- quizzes, dice/oracles, sports simulations.
222
- - `kids`: requires kid-targeted framing (kids/children/curious minds/
223
- bedtime/learning for kids) in the name or description. Lifestyle,
224
- sports, weather, general conversation are NOT kids.
225
-
226
- AVAILABLE CATEGORIES
227
- {taxonomy}
228
-
229
- REFERENCE EXAMPLES
230
- {examples}
231
-
232
- Do not include any text outside the JSON object."""
233
-
234
-
235
- def build_user_prompt(name: str, description: str, readme: str) -> str:
236
- return (
237
- f"App name: {name or '(unknown)'}\n"
238
- f"Short description: {description or '(none)'}\n\n"
239
- f"README excerpt:\n{readme or '(no README available)'}\n\n"
240
- f"Return the JSON now."
241
- )
242
-
243
-
244
- # ──────────────────────────────────────────────────────────────────────
245
- # README fetch + clean (mirrors server/categorize.js)
246
- # ─────────────────────────────────────────────────────────────��────────
247
-
248
- def fetch_readme(space_id: str) -> str:
249
- url = f"https://huggingface.co/spaces/{space_id}/raw/main/README.md"
250
- try:
251
- with urllib.request.urlopen(url, timeout=10, context=_SSL_CTX) as r:
252
- return r.read().decode("utf-8", errors="replace")
253
- except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError):
254
- return ""
255
-
256
-
257
- def clean_readme(raw: str) -> str:
258
- if not raw:
259
- return ""
260
- txt = raw
261
- txt = re.sub(r"^---\n[\s\S]*?\n---\n?", "", txt)
262
- txt = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", txt)
263
- txt = re.sub(r"<img\b[^>]*>", "", txt, flags=re.IGNORECASE)
264
- txt = re.sub(r"\[!\[[^\]]*\]\([^)]+\)\]\([^)]+\)", "", txt)
265
- txt = re.sub(r"</?[a-zA-Z][^>]*>", "", txt)
266
- txt = re.sub(r"\n{3,}", "\n\n", txt)
267
- if len(txt) > README_MAX_CHARS:
268
- cut = txt.rfind("\n\n", 0, README_MAX_CHARS)
269
- if cut > README_MAX_CHARS // 2:
270
- txt = txt[:cut]
271
- else:
272
- txt = txt[:README_MAX_CHARS]
273
- return txt.strip()
274
-
275
-
276
- # ──────────────────────────────────────────────────────────────────────
277
- # LLM call
278
- # ──────────────────────────────────────────────────────────────────────
279
-
280
- def call_llm(hf_token: str, system: str, user: str) -> str | None:
281
- body = json.dumps(
282
- {
283
- "model": MODEL,
284
- "messages": [
285
- {"role": "system", "content": system},
286
- {"role": "user", "content": user},
287
- ],
288
- "temperature": TEMPERATURE,
289
- "max_tokens": MAX_TOKENS,
290
- "response_format": {"type": "json_object"},
291
- }
292
- ).encode("utf-8")
293
- req = urllib.request.Request(
294
- HF_INFERENCE_URL,
295
- data=body,
296
- headers={
297
- "Authorization": f"Bearer {hf_token}",
298
- "Content-Type": "application/json",
299
- # Cloudflare in front of the router 403s the default
300
- # "Python-urllib/x.y" UA. Any reasonable UA passes.
301
- "User-Agent": "reachy-mini-prompt-eval/1.0",
302
- },
303
- method="POST",
304
- )
305
- try:
306
- with urllib.request.urlopen(req, timeout=30, context=_SSL_CTX) as r:
307
- data = json.loads(r.read().decode("utf-8"))
308
- return data.get("choices", [{}])[0].get("message", {}).get("content")
309
- except urllib.error.HTTPError as e:
310
- detail = e.read().decode("utf-8", errors="replace")[:200]
311
- print(f" ✗ LLM HTTP {e.code}: {detail}", file=sys.stderr)
312
- return None
313
- except Exception as e: # noqa: BLE001
314
- print(f" ✗ LLM error: {e}", file=sys.stderr)
315
- return None
316
-
317
-
318
- def extract_json_obj(text: str) -> dict[str, Any] | None:
319
- if not text:
320
- return None
321
- start = text.find("{")
322
- if start == -1:
323
- return None
324
- depth = 0
325
- for i in range(start, len(text)):
326
- c = text[i]
327
- if c == "{":
328
- depth += 1
329
- elif c == "}":
330
- depth -= 1
331
- if depth == 0:
332
- try:
333
- return json.loads(text[start : i + 1])
334
- except json.JSONDecodeError:
335
- return None
336
- return None
337
-
338
-
339
- def sanitize(raw: Any) -> list[str]:
340
- if not isinstance(raw, list):
341
- return []
342
- out: list[str] = []
343
- seen: set[str] = set()
344
- for v in raw:
345
- if not isinstance(v, str):
346
- continue
347
- slug = v.strip().lower()
348
- if not slug or slug in seen or slug not in ALLOWED:
349
- continue
350
- seen.add(slug)
351
- out.append(slug)
352
- if len(out) >= MAX_CATEGORIES_PER_APP:
353
- break
354
- return out
355
-
356
-
357
- # ──────────────────────────────────────────────────────────────────────
358
- # Main
359
- # ──────────────────────────────────────────────────────────────────────
360
-
361
- def read_hf_token() -> str:
362
- if os.environ.get("HF_TOKEN"):
363
- return os.environ["HF_TOKEN"]
364
- env_file = Path(__file__).resolve().parent.parent / ".env"
365
- if env_file.exists():
366
- for line in env_file.read_text().splitlines():
367
- m = re.match(r"^\s*HF_TOKEN\s*=\s*(.*?)\s*$", line)
368
- if m:
369
- v = m.group(1).strip().strip('"').strip("'")
370
- if v:
371
- return v
372
- raise SystemExit("HF_TOKEN not found in env or .env")
373
-
374
-
375
- def fetch_live_classifications() -> list[dict[str, Any]]:
376
- with urllib.request.urlopen(JS_APPS_URL, timeout=30, context=_SSL_CTX) as r:
377
- return json.load(r)["apps"]
378
-
379
-
380
- def main() -> int:
381
- hf_token = read_hf_token()
382
- apps = fetch_live_classifications()
383
- print(f"Loaded {len(apps)} JS apps from prod.\n")
384
-
385
- system = build_system_prompt()
386
- print(f"System prompt: {len(system)} chars, {system.count(chr(10))} lines.\n")
387
-
388
- results: list[dict[str, Any]] = []
389
-
390
- for i, app in enumerate(apps, 1):
391
- sid = app["id"]
392
- name = app.get("name") or sid.split("/")[-1]
393
- desc = (
394
- app.get("description")
395
- or (app.get("extra") or {}).get("cardData", {}).get("short_description")
396
- or ""
397
- )
398
- old_cats = app.get("categories") or []
399
-
400
- raw_readme = fetch_readme(sid)
401
- readme = clean_readme(raw_readme)
402
- user = build_user_prompt(name, desc, readme)
403
-
404
- reply = call_llm(hf_token, system, user)
405
- new_cats = sanitize((extract_json_obj(reply) or {}).get("categories"))
406
-
407
- changed = set(old_cats) != set(new_cats)
408
- marker = "Δ" if changed else " "
409
- print(
410
- f" {marker} ({i:>2}/{len(apps)}) {name[:36]:<37} "
411
- f"old=[{', '.join(old_cats)}]"
412
- + (f" → new=[{', '.join(new_cats)}]" if changed else "")
413
- )
414
-
415
- results.append(
416
- {
417
- "id": sid,
418
- "name": name,
419
- "old": old_cats,
420
- "new": new_cats,
421
- "changed": changed,
422
- }
423
- )
424
- time.sleep(0.25)
425
-
426
- print()
427
- print("─" * 80)
428
- print("DIFF (only changed entries)")
429
- print("─" * 80)
430
- for r in results:
431
- if not r["changed"]:
432
- continue
433
- print(
434
- f" {r['name'][:38]:<40} "
435
- f"[{', '.join(r['old']) or '∅'}] → [{', '.join(r['new']) or '∅'}]"
436
- )
437
-
438
- changed_count = sum(1 for r in results if r["changed"])
439
- print()
440
- print(f"{changed_count}/{len(results)} entries changed.")
441
- return 0
442
-
443
-
444
- if __name__ == "__main__":
445
- sys.exit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/categories.js DELETED
@@ -1,212 +0,0 @@
1
- /**
2
- * Predefined taxonomy for JS Reachy Mini apps.
3
- *
4
- * These slugs are the ONLY valid output values for the LLM
5
- * inference step (anything else is dropped at parse time) and
6
- * the values consumers (mobile shell, website) filter on.
7
- *
8
- * Why a closed list instead of free-form tags
9
- * ──────────────────────────────────────────
10
- * The HF Spaces catalog has no usable categorization for the
11
- * reachy_mini_js_app subset (only platform/SDK tags). We bridge
12
- * the gap by inferring categories with an LLM, but we have to
13
- * constrain the model's output: a closed list keeps category
14
- * pages stable, lets us pre-pick emojis/labels, and avoids the
15
- * "30 near-duplicate slugs" problem you'd get with free-form.
16
- *
17
- * Bumping the taxonomy
18
- * ────────────────────
19
- * Adding, removing or renaming a slug changes the meaning of
20
- * cached entries. Bump TAXONOMY_VERSION when you do that: the
21
- * cache layer compares each entry's `taxonomyVersion` against
22
- * the live one and recomputes stale ones on the next pass.
23
- */
24
-
25
- /**
26
- * Bump this when the slug list OR the descriptions change in a way
27
- * that affects the LLM output. The cache layer invalidates entries
28
- * whose taxonomyVersion is older than this and reclassifies them on
29
- * the next pass. We don't bump it for cosmetic edits (label / emoji)
30
- * since those don't reach the LLM.
31
- *
32
- * History:
33
- * - v1: initial 8-slug taxonomy.
34
- * - v2: added `games`, tightened `kids` + `dev-tools` descriptions,
35
- * switched the prompt to a DECISION ALGORITHM with few-shot.
36
- * - v3: switched from multi-label (up to 3 slugs) to single-label
37
- * (exactly 1 slug). Each app surfaces in exactly one category
38
- * section on the mobile shell - no duplicates across swipers.
39
- * - v4: renamed `dance` to `motion` (broader: marionette, replay,
40
- * choreography without music). Music-driven dance parties
41
- * now belong to `music` since music is what drives them.
42
- */
43
- export const TAXONOMY_VERSION = 4;
44
-
45
- /**
46
- * Canonical category list. Keep slugs short, kebab-case, and
47
- * memorable: they end up in URLs (e.g. `?cat=music`) and in
48
- * filter chips on mobile.
49
- *
50
- * The `description` field is the SOLE source of truth the LLM
51
- * sees - keep them factual, scope-bounded, and example-led so
52
- * the model has signal for both inclusion and exclusion.
53
- */
54
- export const CATEGORIES = [
55
- {
56
- slug: 'music',
57
- label: 'Music & Beats',
58
- emoji: '🎵',
59
- description:
60
- 'Music creation, playback, beats, songs, DJ mixing, instruments, ' +
61
- 'blind-test music games, AND music-driven dance parties (Reachy ' +
62
- 'dances to a song). Requires actual music (rhythm / melody / song). ' +
63
- 'Arbitrary audio (Morse code, alarms, TTS, sound effects) is NOT ' +
64
- 'music. Pure choreography without music belongs to `motion`.',
65
- },
66
- {
67
- slug: 'motion',
68
- label: 'Motion & Movement',
69
- emoji: '🦾',
70
- description:
71
- "Apps that drive Reachy's physical movement on its own: motion " +
72
- 'replay, marionette-style remote control of the body, kinetic ' +
73
- 'shows, choreographies WITHOUT music, expressive body language. ' +
74
- 'If the movement is synced to music, use `music` instead.',
75
- },
76
- {
77
- slug: 'voice',
78
- label: 'Voice & Conversation',
79
- emoji: '🗣️',
80
- description:
81
- 'Reachy talks, listens, or holds a real-time voice ' +
82
- 'conversation: TTS players, LLM-driven chat (OpenAI Realtime, ' +
83
- 'Claude, Perplexity), wake-word demos, daily reports / news / ' +
84
- 'weather read aloud.',
85
- },
86
- {
87
- slug: 'storytelling',
88
- label: 'Stories',
89
- emoji: '📖',
90
- description:
91
- 'Narrative stories WITH plot and characters: interactive ' +
92
- 'fiction, bedtime tales, audio adventures, choose-your-own-' +
93
- 'adventure. NOT for daily reports, news, weather, or Q&A ' +
94
- '(those are `voice`).',
95
- },
96
- {
97
- slug: 'kids',
98
- label: 'For Kids',
99
- emoji: '🧒',
100
- description:
101
- 'Apps that EXPLICITLY target children: the words kids / ' +
102
- "children / 'for curious minds' / bedtime / 'learning for kids' " +
103
- 'must appear in the name or description, OR the app must be ' +
104
- 'obviously kid-targeted. Combines with `storytelling`, `voice`, ' +
105
- 'or `games`. Lifestyle, sports, weather, generic personality / ' +
106
- 'narration / fun framings are NOT kids.',
107
- },
108
- {
109
- slug: 'games',
110
- label: 'Games & Play',
111
- emoji: '🎮',
112
- description:
113
- 'Apps with a play loop: scores, rounds, win/lose conditions, ' +
114
- 'quizzes, puzzles, sports simulations, dice/oracles (magic ' +
115
- '8-ball), arcade-style mini-games.',
116
- },
117
- {
118
- slug: 'vision',
119
- label: 'Vision & Camera',
120
- emoji: '👁️',
121
- description:
122
- "Apps where Reachy's camera DRIVES behaviour: face/hand/pose " +
123
- 'tracking, image classification, gesture detection, visual ' +
124
- 'mimicry. Merely streaming or displaying the camera feed ' +
125
- '(WebRTC demos, remote-control viewers) is NOT vision.',
126
- },
127
- {
128
- slug: 'companion',
129
- label: 'Companion',
130
- emoji: '🤝',
131
- description:
132
- 'Apps with an EXPLICIT emotional / personality / buddy framing ' +
133
- 'in the name or description (companion, buddy, friend, mood, ' +
134
- 'emotional, personality, pet, Tamagotchi-like, "alive", ' +
135
- '"life companion"). Being friendly is not enough.',
136
- },
137
- {
138
- slug: 'dev-tools',
139
- label: 'Dev & Demos',
140
- emoji: '🛠️',
141
- description:
142
- 'RESERVED slug - see DECISION ALGORITHM step 1 in the prompt. ' +
143
- 'Use ONLY for pure technical artefacts (debug utilities, SDK ' +
144
- 'probes, minimal protocol demos, dev-only test spaces) with no ' +
145
- 'end-user experience. When used, it is the SOLE category - ' +
146
- 'never combined with another slug.',
147
- },
148
- ];
149
-
150
- export const ALLOWED_SLUGS = new Set(CATEGORIES.map((c) => c.slug));
151
-
152
- export function isValidSlug(slug) {
153
- return ALLOWED_SLUGS.has(slug);
154
- }
155
-
156
- /**
157
- * Public projection of the taxonomy meant to be shipped to clients
158
- * (mobile shell, website filter chips). We strip the `description`
159
- * field on purpose: it is sized + worded for the LLM prompt and
160
- * carries no UI value (clients render `label` + `emoji`). Render
161
- * order is the index in `CATEGORIES`, surfaced as `order` so a
162
- * client that needs to re-sort (e.g. alphabetical view) keeps the
163
- * canonical order one field-away.
164
- *
165
- * The shape is intentionally minimal and stable:
166
- * `{ slug, label, emoji, order }`. Adding optional fields later
167
- * (e.g. `color`, `shortLabel`) is forward-compatible; renaming or
168
- * dropping one is a breaking change for any client mirror.
169
- */
170
- export function getPublicTaxonomy() {
171
- return CATEGORIES.map((c, index) => ({
172
- slug: c.slug,
173
- label: c.label,
174
- emoji: c.emoji,
175
- order: index,
176
- }));
177
- }
178
-
179
- /**
180
- * Render the taxonomy as a bulleted list for the LLM prompt.
181
- * Format mirrors what the model is asked to output (slug first)
182
- * to nudge it towards copying the exact string back.
183
- */
184
- export function buildLlmCategoryList() {
185
- return CATEGORIES.map((c) => `- ${c.slug}: ${c.description}`).join('\n');
186
- }
187
-
188
- /**
189
- * Sanitize a raw LLM-returned list of slugs:
190
- * - drop non-strings
191
- * - lowercase + trim
192
- * - drop unknown slugs (hallucinations)
193
- * - dedupe while preserving order (the model orders by relevance)
194
- * - cap to MAX_CATEGORIES
195
- *
196
- * Returns a fresh array; never mutates input.
197
- */
198
- export function sanitizeSlugs(raw, maxCategories = 3) {
199
- if (!Array.isArray(raw)) return [];
200
- const seen = new Set();
201
- const out = [];
202
- for (const v of raw) {
203
- if (typeof v !== 'string') continue;
204
- const slug = v.trim().toLowerCase();
205
- if (!slug || seen.has(slug)) continue;
206
- if (!ALLOWED_SLUGS.has(slug)) continue;
207
- seen.add(slug);
208
- out.push(slug);
209
- if (out.length >= maxCategories) break;
210
- }
211
- return out;
212
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/categorize.js DELETED
@@ -1,426 +0,0 @@
1
- /**
2
- * LLM-based category inference for JS Reachy Mini apps.
3
- *
4
- * Pipeline (`categorizeApp`)
5
- * ──────────────────────────
6
- * 1. Fetch the Space's README from HF Hub (raw)
7
- * 2. Strip frontmatter, images, badges, raw HTML, then truncate
8
- * 3. Call a chat LLM via HF Inference Providers (OpenAI-compatible)
9
- * with the predefined taxonomy + the app's name/description
10
- * 4. Parse JSON, validate against ALLOWED_SLUGS, keep up to 3
11
- *
12
- * Robustness contract
13
- * ───────────────────
14
- * `categorizeApp` NEVER throws on transient failure (network,
15
- * 429, malformed JSON). It returns `null`, which the cache layer
16
- * interprets as "not yet categorized; retry on the next pass".
17
- * Hard errors (HF_TOKEN missing) are signalled by a thrown
18
- * `HfTokenMissingError` so the caller can short-circuit the
19
- * whole batch.
20
- */
21
-
22
- import {
23
- buildLlmCategoryList,
24
- sanitizeSlugs,
25
- } from './categories.js';
26
-
27
- // HF Inference Providers - OpenAI-compatible router. Auto-routes
28
- // the request to whichever provider currently serves the model
29
- // (Together, Nebius, Fireworks, Sambanova...). The token must
30
- // have `Inference Providers` access (default for all PRO and
31
- // most FREE tokens since 2025).
32
- const HF_INFERENCE_URL = 'https://router.huggingface.co/v1/chat/completions';
33
-
34
- // 8B model: cheap, fast (~1 s per call), more than enough for a
35
- // closed-list multi-label classification with good descriptions.
36
- // If quality drifts we can swap to 70B without touching anything
37
- // else - the prompt is generic.
38
- const DEFAULT_MODEL = 'meta-llama/Llama-3.1-8B-Instruct';
39
-
40
- // README budget
41
- const README_MAX_CHARS = 3000;
42
-
43
- // Single-label classification: each app gets EXACTLY ONE slug -
44
- // the dominant one. The shape stays `string[]` for forward
45
- // compatibility (if we ever revert to multi-label, no API break),
46
- // but the array always contains 0 or 1 entry. Mobile chips and
47
- // "swipers per category" thus surface each app once and only once.
48
- const MAX_CATEGORIES_PER_APP = 1;
49
-
50
- // LLM call budget
51
- const LLM_TIMEOUT_MS = 30_000;
52
- const LLM_MAX_TOKENS = 120;
53
- const LLM_TEMPERATURE = 0;
54
-
55
- export class HfTokenMissingError extends Error {
56
- constructor() {
57
- super('HF_TOKEN env var is not set; cannot call HF Inference Providers.');
58
- this.name = 'HfTokenMissingError';
59
- }
60
- }
61
-
62
- /**
63
- * Fetch a Space's README from HF Hub. Returns the raw markdown
64
- * string, or `null` if the request fails (404, network, etc.) -
65
- * the caller falls back to "name + description only" in that case,
66
- * which is still enough signal for the LLM on most apps.
67
- */
68
- export async function fetchSpaceReadme(spaceId, { signal } = {}) {
69
- if (!spaceId || typeof spaceId !== 'string') return null;
70
- // The README of a HF Space lives at /spaces/<id>/raw/main/README.md.
71
- // The `raw` endpoint returns the file as-is (no Hub UI wrapping)
72
- // and is anonymous-friendly, so no auth is needed here.
73
- const url = `https://huggingface.co/spaces/${spaceId}/raw/main/README.md`;
74
- try {
75
- const res = await fetch(url, { signal });
76
- if (!res.ok) return null;
77
- return await res.text();
78
- } catch {
79
- return null;
80
- }
81
- }
82
-
83
- /**
84
- * Lightly clean a raw README so the LLM doesn't burn tokens on
85
- * boilerplate (HF frontmatter, badges, images) and so the actual
86
- * prose surfaces above the truncation budget.
87
- *
88
- * We keep transformations conservative: we never edit the
89
- * surrounding prose, we just delete decorative tokens. Anything
90
- * cosmetic-only that clearly isn't signal for classification
91
- * (badges, images, raw HTML).
92
- */
93
- export function cleanReadme(raw) {
94
- if (!raw || typeof raw !== 'string') return '';
95
- let txt = raw;
96
-
97
- // 1. Strip the YAML frontmatter at the very top (HF Spaces
98
- // ship a mandatory `---\n...metadata...\n---` block whose
99
- // fields are already exposed to us via the catalog payload,
100
- // so feeding them to the LLM is pure noise).
101
- txt = txt.replace(/^---\n[\s\S]*?\n---\n?/, '');
102
-
103
- // 2. Drop image markdown (`![alt](url)`) and HTML <img> tags.
104
- // Vision apps tend to load up READMEs with screenshots and
105
- // GIFs; the alt text is sometimes useful but more often it's
106
- // "demo.gif" - low signal/noise ratio.
107
- txt = txt.replace(/!\[[^\]]*\]\([^)]+\)/g, '');
108
- txt = txt.replace(/<img\b[^>]*>/gi, '');
109
-
110
- // 3. Strip shields.io / GitHub badges (markdown links that
111
- // wrap an image). They survive (2) only when nested.
112
- txt = txt.replace(/\[!\[[^\]]*\]\([^)]+\)\]\([^)]+\)/g, '');
113
-
114
- // 4. Generic HTML stripping. Most READMEs are pure markdown,
115
- // but some authors embed `<details>`, `<sub>`, `<center>`
116
- // blocks. Keep the inner text, drop the tags.
117
- txt = txt.replace(/<\/?[a-zA-Z][^>]*>/g, '');
118
-
119
- // 5. Collapse runs of blank lines so trimming doesn't waste
120
- // tokens on the gap.
121
- txt = txt.replace(/\n{3,}/g, '\n\n');
122
-
123
- // 6. Truncate. We slice at the paragraph boundary closest to
124
- // the budget so we don't end mid-sentence.
125
- if (txt.length > README_MAX_CHARS) {
126
- const cut = txt.lastIndexOf('\n\n', README_MAX_CHARS);
127
- txt = txt.slice(0, cut > README_MAX_CHARS / 2 ? cut : README_MAX_CHARS);
128
- }
129
-
130
- return txt.trim();
131
- }
132
-
133
- /**
134
- * Few-shot examples woven into the system prompt.
135
- *
136
- * Each entry encodes a pitfall the v1 prompt fell into during the
137
- * 24-app eval (see `scripts/evaluate-prompt-v2.py`). Keep this list
138
- * tight - past ~10 examples the model starts pattern-matching
139
- * literally on the example names rather than applying the rules.
140
- *
141
- * Format: [name, description, expected_slugs, brief_justification]
142
- */
143
- const FEW_SHOT_EXAMPLES = [
144
- [
145
- 'Reachy Morse',
146
- "Send Morse code through Reachy's speaker.",
147
- ['dev-tools'],
148
- '(STEP 1 veto: pure technical artefact. NOT music.)',
149
- ],
150
- [
151
- 'WebRTC Demo',
152
- 'Minimal WebRTC connection between Reachy and the browser.',
153
- ['dev-tools'],
154
- '(STEP 1 veto: protocol demo. NOT vision.)',
155
- ],
156
- [
157
- 'TTS Reachy Mini',
158
- "Browser TTS that plays out of Reachy Mini's speaker.",
159
- ['voice'],
160
- '(USER-FACING speech output is voice, NOT dev-tools.)',
161
- ],
162
- [
163
- 'Reachy Mochi - Emotional Companion',
164
- 'Your pocket buddy that develops a mood and personality over time.',
165
- ['companion'],
166
- '(explicit emotional/companion framing)',
167
- ],
168
- [
169
- 'Reachy Alive',
170
- '(README empty; name suggests autonomy and life-like presence)',
171
- ['companion'],
172
- "(USE THE NAME when the README is empty; 'alive' = companion-like)",
173
- ],
174
- [
175
- 'Daily Surf Report',
176
- "Reachy reads today's surf report out loud.",
177
- ['voice'],
178
- '(NOT storytelling - a report has no narrative arc. ' +
179
- 'NOT kids - surfing/sports are not kid-targeted.)',
180
- ],
181
- [
182
- 'Music Quiz',
183
- 'Play a blind test music game with a dancing Reachy.',
184
- ['music'],
185
- '(single dominant slug - music wins over games because the app ' +
186
- "is primarily a music blind-test; the dancing is a side effect " +
187
- 'of the music and is captured by `music` too)',
188
- ],
189
- [
190
- 'Mime Bot',
191
- 'Reachy mimics your face live from your webcam.',
192
- ['vision'],
193
- '(NOT companion - mimicry is visual, no emotional framing.)',
194
- ],
195
- ];
196
-
197
- function renderFewShot() {
198
- return FEW_SHOT_EXAMPLES.map(([name, desc, slugs, hint]) => {
199
- const slugsJson = JSON.stringify(slugs);
200
- return (
201
- ` - ${JSON.stringify(name)}: ${JSON.stringify(desc)}\n` +
202
- ` → {"categories": ${slugsJson}} ${hint}`
203
- );
204
- }).join('\n');
205
- }
206
-
207
- /**
208
- * Build the chat messages handed to the LLM.
209
- *
210
- * The system prompt is structured as a 3-step DECISION ALGORITHM
211
- * rather than a flat list of rules, because the 8B-class model we
212
- * use (Llama-3.1-8B-Instruct) follows imperative procedures more
213
- * reliably than soft constraints. The `dev-tools` veto in STEP 1
214
- * is what stops the model from silently combining it with other
215
- * slugs on user-facing apps.
216
- *
217
- * The few-shot examples below the rules cover the v1 pitfalls
218
- * (companion hallucinations, music-on-audio, kids-on-personas,
219
- * storytelling-on-reports). Six is the sweet spot - more starts
220
- * over-fitting on example wording.
221
- */
222
- function buildMessages({ name, description, readme }) {
223
- const taxonomy = buildLlmCategoryList();
224
- const examples = renderFewShot();
225
- const system = `You classify a Reachy Mini robot app into a CLOSED list of categories.
226
-
227
- OUTPUT FORMAT
228
- Return ONLY a single JSON object: {"categories": ["slug"]}.
229
- Pick EXACTLY ONE slug - the single dominant category that best
230
- captures the app's primary identity. Use the EXACT slug. The list
231
- always contains 0 or 1 entry.
232
- No prose, no code fences, no commentary outside the JSON.
233
-
234
- DECISION ALGORITHM (apply in order)
235
-
236
- STEP 1 - \`dev-tools\` veto
237
- Is this app a PURE technical artefact with no user-facing experience
238
- beyond "here is how the SDK / API works"?
239
- Examples that pass the veto: WebRTC demo, SDK probe, debug utility,
240
- raw remote-control interface, dev-only test space.
241
- Examples that DO NOT pass the veto (they are user-facing apps):
242
- TTS players, voice chat, music apps, storytelling, companions -
243
- even when the README is dev-heavy.
244
- - YES -> return {"categories": ["dev-tools"]} and STOP.
245
- - NO -> continue to STEP 2.
246
-
247
- STEP 2 - Pick the SINGLE most dominant user-facing slug from the list
248
- below. Choose the slug that captures the app's primary identity, not
249
- every aspect it touches. When two slugs feel equally fitting, pick the
250
- one that a user would name FIRST when describing the app in one word.
251
- Examples of tie-breaks:
252
- - music-driven dance party (Reachy dances to a song) -> \`music\`.
253
- The music is what drives the experience.
254
- - pure choreography / marionette / motion replay without music ->
255
- \`motion\`. The movement is the experience.
256
- - storytelling + kids app -> prefer \`kids\` if it explicitly targets
257
- children, \`storytelling\` otherwise.
258
- - vision + games app -> prefer \`games\` if there is a play loop,
259
- \`vision\` if it is mostly a perception demo.
260
- If the README is empty or very sparse, USE THE NAME AND DESCRIPTION
261
- as the primary signal - do not bail to an empty list just because the
262
- README is thin.
263
-
264
- STEP 3 - Strict slug rules (each must hold, or DO NOT use the slug)
265
- - \`companion\`: requires EXPLICIT emotional / personality / buddy
266
- framing (companion, buddy, friend, mood, emotional, personality,
267
- pet, Tamagotchi-like, "alive", "life companion"). Being friendly is
268
- not enough.
269
- - \`music\`: requires actual music - rhythm, melody, songs, beats, DJ
270
- sets, instruments, music quizzes. Arbitrary audio (Morse, alarms,
271
- TTS, sound effects) is NOT music.
272
- - \`vision\`: requires the camera to DRIVE behaviour (tracking,
273
- classification, mimicry). Merely streaming or displaying the camera
274
- (WebRTC demos, remote-control viewers) is NOT vision.
275
- - \`storytelling\`: requires a narrative ARC - plot, characters, scenes.
276
- Daily reports, news, weather, Q&A are NOT storytelling (they are
277
- \`voice\`).
278
- - \`games\`: requires a play loop - score, rounds, win/lose, puzzles,
279
- quizzes, dice/oracles, sports simulations.
280
- - \`kids\`: requires kid-targeted framing (kids/children/curious minds/
281
- bedtime/learning for kids) in the name or description. Lifestyle,
282
- sports, weather, general conversation are NOT kids.
283
-
284
- AVAILABLE CATEGORIES
285
- ${taxonomy}
286
-
287
- REFERENCE EXAMPLES
288
- ${examples}
289
-
290
- Do not include any text outside the JSON object.`;
291
-
292
- const user =
293
- `App name: ${name || '(unknown)'}\n` +
294
- `Short description: ${description || '(none)'}\n\n` +
295
- `README excerpt:\n${readme || '(no README available)'}\n\n` +
296
- 'Return the JSON now.';
297
-
298
- return [
299
- { role: 'system', content: system },
300
- { role: 'user', content: user },
301
- ];
302
- }
303
-
304
- /**
305
- * Best-effort JSON extraction. Some 8B models still wrap the
306
- * answer in ``` fences or prepend "Sure, here you go:". We grab
307
- * the first balanced `{...}` block and parse that.
308
- */
309
- function extractJsonObject(text) {
310
- if (!text || typeof text !== 'string') return null;
311
- const start = text.indexOf('{');
312
- if (start === -1) return null;
313
- let depth = 0;
314
- for (let i = start; i < text.length; i++) {
315
- const ch = text[i];
316
- if (ch === '{') depth++;
317
- else if (ch === '}') {
318
- depth--;
319
- if (depth === 0) {
320
- const slice = text.slice(start, i + 1);
321
- try {
322
- return JSON.parse(slice);
323
- } catch {
324
- return null;
325
- }
326
- }
327
- }
328
- }
329
- return null;
330
- }
331
-
332
- /**
333
- * Call the HF Inference Providers chat endpoint. Returns the
334
- * raw assistant message string, or `null` on any error.
335
- */
336
- async function callLlm({ messages, model, signal }) {
337
- const token = process.env.HF_TOKEN;
338
- if (!token) throw new HfTokenMissingError();
339
-
340
- const body = {
341
- model,
342
- messages,
343
- temperature: LLM_TEMPERATURE,
344
- max_tokens: LLM_MAX_TOKENS,
345
- // `response_format` is honoured by some providers (Nebius,
346
- // Together) but ignored by others. It's a free upgrade when
347
- // present, harmless otherwise; the JSON-extractor below is
348
- // the real safety net.
349
- response_format: { type: 'json_object' },
350
- };
351
-
352
- let res;
353
- try {
354
- res = await fetch(HF_INFERENCE_URL, {
355
- method: 'POST',
356
- headers: {
357
- 'Authorization': `Bearer ${token}`,
358
- 'Content-Type': 'application/json',
359
- },
360
- body: JSON.stringify(body),
361
- signal,
362
- });
363
- } catch (err) {
364
- console.warn(`[categorize] LLM fetch failed: ${err.message}`);
365
- return null;
366
- }
367
-
368
- if (!res.ok) {
369
- const detail = await res.text().catch(() => '');
370
- console.warn(
371
- `[categorize] LLM HTTP ${res.status}: ${detail.slice(0, 200)}`,
372
- );
373
- return null;
374
- }
375
-
376
- let json;
377
- try {
378
- json = await res.json();
379
- } catch {
380
- return null;
381
- }
382
- return json?.choices?.[0]?.message?.content ?? null;
383
- }
384
-
385
- /**
386
- * Public entry point.
387
- *
388
- * Returns a string[] of validated slugs (0-3 items), or `null`
389
- * on transient failure so the caller can mark the entry "needs
390
- * retry" without writing a misleading empty list.
391
- *
392
- * Treat an empty array `[]` as "the LLM looked and concluded
393
- * none fit" - that's a valid, cacheable outcome.
394
- */
395
- export async function categorizeApp({
396
- name,
397
- description,
398
- spaceId,
399
- model = DEFAULT_MODEL,
400
- } = {}) {
401
- if (!spaceId) return null;
402
-
403
- const ctrl = new AbortController();
404
- const timeoutId = setTimeout(() => ctrl.abort(), LLM_TIMEOUT_MS);
405
-
406
- try {
407
- const rawReadme = await fetchSpaceReadme(spaceId, { signal: ctrl.signal });
408
- const readme = cleanReadme(rawReadme);
409
-
410
- const messages = buildMessages({ name, description, readme });
411
- const reply = await callLlm({ messages, model, signal: ctrl.signal });
412
- if (reply == null) return null;
413
-
414
- const obj = extractJsonObject(reply);
415
- if (!obj || !Array.isArray(obj.categories)) {
416
- console.warn(
417
- `[categorize] ${spaceId}: malformed LLM reply (truncated): ` +
418
- `${reply.slice(0, 120)}`,
419
- );
420
- return null;
421
- }
422
- return sanitizeSlugs(obj.categories, MAX_CATEGORIES_PER_APP);
423
- } finally {
424
- clearTimeout(timeoutId);
425
- }
426
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/categoryCache.js DELETED
@@ -1,290 +0,0 @@
1
- /**
2
- * Persistent cache for inferred app categories, backed by a
3
- * HuggingFace dataset.
4
- *
5
- * Why a dataset (not a local file)
6
- * ────────────────────────────────
7
- * The website runs in a Docker HF Space. The container's
8
- * filesystem is wiped on every rebuild (and rebuilds happen
9
- * on every push, every model update, every Space restart).
10
- * Re-running 200 LLM calls every cold start would be wasteful
11
- * and slow the user-visible /api/js-apps for the first 30 s.
12
- *
13
- * Pushing the cache to a dataset gives us:
14
- * 1. Persistence across rebuilds and machine moves
15
- * 2. A versioned audit log of how categories evolve
16
- * 3. A single source of truth other tooling can consume
17
- * (the mobile shell could even read the dataset directly
18
- * if it ever wanted to bypass the website).
19
- *
20
- * Storage shape
21
- * ─────────────
22
- * <dataset>/categories.json
23
- *
24
- * {
25
- * "version": 1,
26
- * "taxonomyVersion": 1,
27
- * "updatedAt": "2026-05-10T11:08:42Z",
28
- * "entries": {
29
- * "<spaceId>": {
30
- * "lastModified": "2026-05-08T22:13:01Z",
31
- * "categories": ["storytelling", "kids", "voice"],
32
- * "categorizedAt": "2026-05-10T11:08:42Z",
33
- * "taxonomyVersion": 1
34
- * }
35
- * }
36
- * }
37
- *
38
- * In-memory tier
39
- * ──────────────
40
- * The Map<spaceId, entry> is the hot path. The dataset is
41
- * loaded once at boot and only flushed when entries actually
42
- * change (the warmup batch buffers writes and flushes once
43
- * at the end). All synchronous access goes through the Map.
44
- */
45
-
46
- import { commit, createRepo } from '@huggingface/hub';
47
-
48
- import { TAXONOMY_VERSION } from './categories.js';
49
-
50
- // Default location: a per-user dataset that the HF_TOKEN owner
51
- // definitely has write access to. Override with the env var
52
- // when promoting to the org-owned `pollen-robotics/...` dataset.
53
- const DEFAULT_DATASET = 'tfrere/reachy-mini-app-categories';
54
-
55
- const CACHE_FILE_PATH = 'categories.json';
56
- const CACHE_FORMAT_VERSION = 1;
57
-
58
- class CategoryCache {
59
- constructor() {
60
- this.entries = new Map();
61
- this.repoName = process.env.HF_CATEGORIES_DATASET || DEFAULT_DATASET;
62
- this.loaded = false;
63
- this.dirty = false;
64
- // Concurrency guard for `flush()` - we never want two
65
- // commit() calls fighting for the same parent commit.
66
- this.flushing = false;
67
- }
68
-
69
- /**
70
- * Load the dataset cache into memory. Best-effort: a missing
71
- * dataset, a 404, or a malformed JSON all collapse to "start
72
- * fresh, the warmup will repopulate". We never let cache load
73
- * failure block the server boot.
74
- */
75
- async load() {
76
- if (this.loaded) return;
77
- this.loaded = true;
78
-
79
- const url = `https://huggingface.co/datasets/${this.repoName}/resolve/main/${CACHE_FILE_PATH}`;
80
- try {
81
- const res = await fetch(url, {
82
- // Send the token even on a public dataset: it lets HF
83
- // bump our rate limit and keeps the path identical for
84
- // a future private dataset migration.
85
- headers: process.env.HF_TOKEN
86
- ? { Authorization: `Bearer ${process.env.HF_TOKEN}` }
87
- : undefined,
88
- });
89
- if (!res.ok) {
90
- if (res.status === 404) {
91
- console.log(
92
- `[CategoryCache] Dataset ${this.repoName} or ${CACHE_FILE_PATH} ` +
93
- `not found yet - starting empty.`,
94
- );
95
- } else {
96
- console.warn(
97
- `[CategoryCache] HTTP ${res.status} loading cache from ` +
98
- `${this.repoName}, starting empty.`,
99
- );
100
- }
101
- return;
102
- }
103
- const data = await res.json();
104
- const entries = data?.entries || {};
105
- let kept = 0;
106
- let staleTaxonomy = 0;
107
- for (const [id, raw] of Object.entries(entries)) {
108
- if (!raw || typeof raw !== 'object') continue;
109
- // Drop entries from a previous taxonomy: their slugs
110
- // may no longer exist or may have shifted meaning.
111
- // The warmup will re-run them.
112
- if (raw.taxonomyVersion !== TAXONOMY_VERSION) {
113
- staleTaxonomy++;
114
- continue;
115
- }
116
- this.entries.set(id, {
117
- lastModified: raw.lastModified || null,
118
- categories: Array.isArray(raw.categories) ? raw.categories : [],
119
- categorizedAt: raw.categorizedAt || null,
120
- taxonomyVersion: raw.taxonomyVersion,
121
- });
122
- kept++;
123
- }
124
- console.log(
125
- `[CategoryCache] Loaded ${kept} entries from ${this.repoName}` +
126
- (staleTaxonomy ? ` (dropped ${staleTaxonomy} stale taxonomy)` : ''),
127
- );
128
- } catch (err) {
129
- console.warn(
130
- `[CategoryCache] Load failed (${err.message}); starting empty.`,
131
- );
132
- }
133
- }
134
-
135
- get(spaceId) {
136
- return this.entries.get(spaceId) || null;
137
- }
138
-
139
- /**
140
- * Decide whether `spaceId` needs a fresh classification call.
141
- * It does when:
142
- * - we have no entry at all, OR
143
- * - the Space's `lastModified` has moved past our cached one
144
- * (the README may have changed - re-classify), OR
145
- * - the taxonomy version moved (handled at load() time, but
146
- * belt-and-braces for hot reloads).
147
- */
148
- needsCategorization(spaceId, lastModified) {
149
- const entry = this.entries.get(spaceId);
150
- if (!entry) return true;
151
- if (entry.taxonomyVersion !== TAXONOMY_VERSION) return true;
152
- if (lastModified && entry.lastModified !== lastModified) return true;
153
- return false;
154
- }
155
-
156
- set(spaceId, { categories, lastModified }) {
157
- if (!Array.isArray(categories)) return;
158
- const next = {
159
- lastModified: lastModified || null,
160
- categories: [...categories],
161
- categorizedAt: new Date().toISOString(),
162
- taxonomyVersion: TAXONOMY_VERSION,
163
- };
164
- const prev = this.entries.get(spaceId);
165
- // Skip the dirty flag if nothing actually changed - avoids
166
- // a useless commit when a refresh confirms the same labels.
167
- if (
168
- prev &&
169
- prev.lastModified === next.lastModified &&
170
- prev.taxonomyVersion === next.taxonomyVersion &&
171
- JSON.stringify(prev.categories) === JSON.stringify(next.categories)
172
- ) {
173
- return;
174
- }
175
- this.entries.set(spaceId, next);
176
- this.dirty = true;
177
- }
178
-
179
- /**
180
- * Persist the in-memory cache to the dataset (one commit, one
181
- * file). No-op if nothing has changed since the last flush.
182
- *
183
- * Auto-creates the dataset on first write if it doesn't exist
184
- * yet (so a brand-new `HF_CATEGORIES_DATASET` value bootstraps
185
- * cleanly without manual setup).
186
- */
187
- async flush() {
188
- if (!this.dirty || this.flushing) return;
189
- if (!process.env.HF_TOKEN) {
190
- console.warn('[CategoryCache] HF_TOKEN missing; skipping flush.');
191
- return;
192
- }
193
- this.flushing = true;
194
- try {
195
- const payload = this.serialize();
196
- const blob = new Blob([JSON.stringify(payload, null, 2)], {
197
- type: 'application/json',
198
- });
199
-
200
- const repo = { type: 'dataset', name: this.repoName };
201
- const credentials = { accessToken: process.env.HF_TOKEN };
202
-
203
- // First attempt: plain commit. If the dataset doesn't
204
- // exist yet, the SDK throws and we fall through to
205
- // create-then-commit. We never assume the dataset exists
206
- // - that lets a fresh deploy auto-bootstrap.
207
- try {
208
- await commit({
209
- repo,
210
- credentials,
211
- title: `Update categories (${this.entries.size} apps)`,
212
- operations: [
213
- {
214
- operation: 'addOrUpdate',
215
- path: CACHE_FILE_PATH,
216
- content: blob,
217
- },
218
- ],
219
- });
220
- } catch (err) {
221
- const msg = err?.message || '';
222
- const looksMissing =
223
- msg.includes('404') ||
224
- msg.toLowerCase().includes('not found') ||
225
- msg.toLowerCase().includes('does not exist');
226
- if (!looksMissing) throw err;
227
- console.log(
228
- `[CategoryCache] Dataset ${this.repoName} missing - creating it.`,
229
- );
230
- await createRepo({
231
- repo,
232
- credentials,
233
- private: false,
234
- // Re-using the same blob so the initial commit ships
235
- // the cache content (instead of an empty repo
236
- // followed by a no-op commit).
237
- files: [
238
- {
239
- path: CACHE_FILE_PATH,
240
- content: await blob.arrayBuffer(),
241
- },
242
- ],
243
- });
244
- }
245
-
246
- this.dirty = false;
247
- console.log(
248
- `[CategoryCache] Flushed ${this.entries.size} entries to ${this.repoName}`,
249
- );
250
- } catch (err) {
251
- // We deliberately swallow flush errors so a HF outage
252
- // doesn't break the running server. The next set() will
253
- // re-flag dirty=true and the next flush() will retry.
254
- console.error(
255
- `[CategoryCache] Flush failed: ${err?.message || err}`,
256
- );
257
- } finally {
258
- this.flushing = false;
259
- }
260
- }
261
-
262
- serialize() {
263
- const entries = {};
264
- for (const [id, entry] of this.entries) {
265
- entries[id] = entry;
266
- }
267
- return {
268
- version: CACHE_FORMAT_VERSION,
269
- taxonomyVersion: TAXONOMY_VERSION,
270
- updatedAt: new Date().toISOString(),
271
- entries,
272
- };
273
- }
274
-
275
- /**
276
- * Diagnostic snapshot for /api/js-apps's `categorization`
277
- * sub-payload. Lets the mobile shell decide whether to show
278
- * "loading categories..." or to render the chips immediately.
279
- */
280
- stats() {
281
- return {
282
- total: this.entries.size,
283
- dataset: this.repoName,
284
- taxonomyVersion: TAXONOMY_VERSION,
285
- };
286
- }
287
- }
288
-
289
- // Singleton: there's only one cache per server process.
290
- export const categoryCache = new CategoryCache();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/index.js CHANGED
@@ -1,113 +1,12 @@
1
- import compression from 'compression';
2
  import express from 'express';
3
- import { existsSync, readFileSync } from 'fs';
4
  import path from 'path';
5
  import { fileURLToPath } from 'url';
6
 
7
- import { categorizeApp, HfTokenMissingError } from './categorize.js';
8
- import { categoryCache } from './categoryCache.js';
9
- import { getPublicTaxonomy } from './categories.js';
10
- import { mintEphemeralKeyHandler } from './openaiEphemeral.js';
11
-
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
 
14
- // Load `.env` from the repo root in dev. In production (HF Space)
15
- // the platform already injects the secrets as env vars, so this
16
- // loader silently no-ops. We avoid the `dotenv` dep on purpose -
17
- // the format is trivial, and reproducing it inline keeps the
18
- // runtime closure tiny.
19
- (function loadDotenv() {
20
- try {
21
- const envPath = path.join(__dirname, '..', '.env');
22
- if (!existsSync(envPath)) return;
23
- const text = readFileSync(envPath, 'utf8');
24
- for (const line of text.split(/\r?\n/)) {
25
- const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/i);
26
- if (!m) continue;
27
- const [, key, raw] = m;
28
- let value = raw;
29
- if (
30
- (value.startsWith('"') && value.endsWith('"')) ||
31
- (value.startsWith("'") && value.endsWith("'"))
32
- ) {
33
- value = value.slice(1, -1);
34
- }
35
- // Existing env wins (so `HF_TOKEN=foo node …` overrides .env).
36
- if (process.env[key] === undefined) process.env[key] = value;
37
- }
38
- } catch {
39
- /* best-effort - missing or malformed .env never blocks boot */
40
- }
41
- })();
42
-
43
  const app = express();
44
  const PORT = process.env.PORT || 7860;
45
 
46
- // gzip/brotli compression on every response. Critical for the
47
- // catalog endpoints (`/api/apps`, `/api/js-apps`) which return
48
- // ~40KB of JSON dominated by repeated keys ("apps", "id", "extra",
49
- // "cardData"…) - gzip cuts that to ~6KB on the wire. The Express
50
- // `compression` middleware:
51
- // - skips responses already encoded (no double-encoding),
52
- // - skips responses below the `threshold` (default 1KB - tiny
53
- // payloads stay verbatim since the gzip framing would dwarf
54
- // the savings),
55
- // - honours the client's `Accept-Encoding`, falling back to
56
- // identity when the client doesn't speak gzip/br.
57
- // No streaming endpoints in this server (every route ends in
58
- // `res.json()` or `res.sendFile()`), so compression is unconditionally
59
- // safe. The default `level: 6` is the right CPU/ratio trade-off for
60
- // JSON.
61
- app.use(compression());
62
-
63
- // JSON body parsing for the handful of POST routes that consume
64
- // structured payloads (currently `/api/openai/ephemeral`). The 8KB
65
- // cap is intentionally tiny because none of our endpoints accept
66
- // large bodies, and a tight limit drops obvious abuse early.
67
- app.use(express.json({ limit: '8kb' }));
68
-
69
- // CORS allowlist for cross-origin API consumers. Same-origin browser
70
- // calls from this Space stay unaffected. The mobile shell runs from
71
- // `https://tauri.localhost` (iOS WKWebView), `http://tauri.localhost`
72
- // (Android WebView), and the desktop dev preview from
73
- // `http://localhost:1422` (Vite). We do NOT use a wildcard origin
74
- // because every allowed call expects `Authorization: Bearer …`, and
75
- // `Access-Control-Allow-Origin: *` is incompatible with credentialed
76
- // CORS in any practical setup.
77
- const CORS_ALLOWED_ORIGINS = new Set([
78
- 'https://tauri.localhost',
79
- 'http://tauri.localhost',
80
- 'http://localhost:1422',
81
- 'http://localhost:1420',
82
- ]);
83
-
84
- app.use((req, res, next) => {
85
- const origin = req.headers.origin;
86
- if (origin && CORS_ALLOWED_ORIGINS.has(origin)) {
87
- res.setHeader('Access-Control-Allow-Origin', origin);
88
- res.setHeader('Vary', 'Origin');
89
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
90
- res.setHeader(
91
- 'Access-Control-Allow-Headers',
92
- 'Authorization, Content-Type',
93
- );
94
- // Expose `Age` so cross-origin JS clients (mobile shell, desktop
95
- // store, anything not running same-origin on this Space) can
96
- // read the server-side cache age. The header lives in the
97
- // CORS-safelisted set only for a hardcoded handful of fields;
98
- // `Age` is NOT in that set, so without this header browser
99
- // `fetch()` callers would see `null` from `headers.get('age')`.
100
- // We could also expose `ETag` here for clients that want to
101
- // do manual `If-None-Match` revalidation, but the browser
102
- // handles ETag transparently in its own HTTP cache, so JS
103
- // never needs to see it.
104
- res.setHeader('Access-Control-Expose-Headers', 'Age');
105
- res.setHeader('Access-Control-Max-Age', '600');
106
- }
107
- if (req.method === 'OPTIONS') return res.sendStatus(204);
108
- next();
109
- });
110
-
111
  // Cache configuration
112
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
113
  const OFFICIAL_APP_LIST_URL = 'https://huggingface.co/datasets/pollen-robotics/reachy-mini-official-app-store/raw/main/app-list.json';
@@ -115,141 +14,6 @@ const HF_SPACES_API = 'https://huggingface.co/api/spaces';
115
  // Note: HF API doesn't support pagination with filter=, so we use a high limit
116
  const HF_SPACES_LIMIT = 1000;
117
 
118
- /**
119
- * Standard HTTP caching for the catalog GET endpoints
120
- * (`/api/apps`, `/api/js-apps`).
121
- *
122
- * Why bake this into a helper instead of inlining the same two
123
- * `setHeader` calls in every route:
124
- * 1. Both endpoints share the same upstream cache state
125
- * (`appsCache.lastFetch`) so they SHOULD emit a coherent
126
- * `Age` value - any drift between routes would silently
127
- * mislead clients about cache staleness.
128
- * 2. The `Cache-Control` directives below were chosen carefully;
129
- * a future contributor copy-pasting one route to start a new
130
- * catalog projection should inherit them rather than rolling
131
- * their own.
132
- *
133
- * Cache-Control: `public, max-age=60, stale-while-revalidate=300`
134
- * - `public`: response is safe to store in shared caches (the
135
- * payload is identical for every caller, no per-user data).
136
- * - `max-age=60`: clients + intermediaries may serve this
137
- * response for up to 60 s without revalidating. The upstream
138
- * `appsCache` already deduplicates within a 5-minute window
139
- * server-side, so 60 s here means the network sees at most
140
- * 1 hit/minute per cache key per intermediate even under
141
- * burst load (10k mobile shells waking up at the same time).
142
- * - `stale-while-revalidate=300`: for a further 5 minutes after
143
- * the response goes stale, intermediaries may serve the
144
- * stale copy while revalidating in the background. This
145
- * absorbs sudden traffic spikes without ever blocking the
146
- * user on a cold-cache fetch.
147
- *
148
- * `Age` (RFC 7234 §5.1) replaces the `cacheAge` field we used to
149
- * pack into the response body. Pulling the age out of the body
150
- * was a strict prerequisite for ETag-based revalidation: Express's
151
- * default ETag is a hash of the response body, and a body that
152
- * carries a counter that increments every second produces a fresh
153
- * ETag every second, which makes `If-None-Match` permanently
154
- * negative and turns the ETag into dead weight. With `cacheAge`
155
- * promoted to a header, the body becomes a pure function of the
156
- * cache contents, the ETag becomes stable across requests that
157
- * hit the same cache snapshot, and clients sending `If-None-Match`
158
- * get cheap 304s instead of re-downloading 40 KB of JSON.
159
- */
160
- function setCatalogCacheHeaders(res, lastFetchMs) {
161
- res.setHeader(
162
- 'Cache-Control',
163
- 'public, max-age=60, stale-while-revalidate=300',
164
- );
165
- const ageSeconds = lastFetchMs
166
- ? Math.max(0, Math.round((Date.now() - lastFetchMs) / 1000))
167
- : 0;
168
- res.setHeader('Age', String(ageSeconds));
169
- }
170
-
171
- // Tag that gates the JS-only subset surfaced by /api/js-apps and
172
- // fed to the LLM categorizer. Mirrors the filter the mobile shell
173
- // applies today client-side; the route lets us retire that filter
174
- // from the mobile codebase down the line.
175
- const JS_APP_TAG = 'reachy_mini_js_app';
176
-
177
- // =====================================================================
178
- // App icon convention
179
- // =====================================================================
180
- //
181
- // Convention: an app commits `public/icon.svg` (preferred) or
182
- // `public/icon.png` in its HF Space repository. When present, the
183
- // mobile shell + desktop store render it as the app glyph instead
184
- // of the front-matter `emoji:` codepoint.
185
- //
186
- // Why `public/` and not the repo root?
187
- // - Vite already copies `public/*` verbatim to `dist/` at build,
188
- // where nginx serves it at `/icon.svg`. The same file is
189
- // therefore the favicon, the `mountHost({ appIconUrl })` value,
190
- // AND the store glyph - one source of truth, no `cp` script,
191
- // no risk of the two copies drifting apart.
192
- // - HF `resolve/main/public/icon.svg` works the same as
193
- // `resolve/main/icon.svg`: any path inside the repo is
194
- // reachable, so the catalog still grabs the bytes without
195
- // waking the Space's nginx.
196
- //
197
- // We resolve the icon ONCE at indexing time (here) rather than
198
- // probing per-client because:
199
- // 1. We already pull `siblings` from `?full=true` (one cheap
200
- // hub call returns the file list for every app), so the
201
- // lookup is a pure JS filter, no extra network.
202
- // 2. Clients see a single field (`iconUrl`) in the payload and
203
- // don't have to know about HF resolve URLs, LFS pointers,
204
- // or the candidate-order race ("SVG wins if both exist").
205
- // 3. The HF API caps probes at ~hub side; doing it server-side
206
- // keeps fanout under a 5-minute TTL behind ONE token, instead
207
- // of every mobile shell hammering `huggingface.co/resolve/`
208
- // to discover icons.
209
- //
210
- // Resolution order: `public/icon.svg` → `public/icon.png`. SVG
211
- // first because the same asset scales cleanly across every mount
212
- // point (small rail tile, larger pinned tile, iframe header) from
213
- // a single file. Extra formats can be added to `ICON_CANDIDATES`
214
- // if needed; order matters - the first match wins.
215
- const ICON_CANDIDATES = ['public/icon.svg', 'public/icon.png'];
216
-
217
- /**
218
- * Look for a standard app icon file at the conventional location.
219
- * Returns the absolute HF resolve URL when found, `null` otherwise.
220
- *
221
- * We hit `resolve/main/` (not `raw/main/`) so:
222
- * - LFS pointers follow transparently (large PNGs work).
223
- * - `Content-Type` comes from the extension, which `<img>` needs.
224
- * - The URL is cacheable cross-session by the browser, so
225
- * repeated mounts of the same app glyph don't re-fetch.
226
- */
227
- function findIconUrl(spaceId, siblings) {
228
- if (!spaceId || !Array.isArray(siblings)) return null;
229
- // Build a Set of repo-relative filenames for O(1) candidate
230
- // lookups. HF returns `siblings` as `[{ rfilename: "path/in/repo" }, ...]`;
231
- // we keep the full path because the convention now lives under
232
- // `public/` rather than at the repo root.
233
- const files = new Set();
234
- for (const s of siblings) {
235
- const name = s && typeof s.rfilename === 'string' ? s.rfilename : null;
236
- if (!name) continue;
237
- files.add(name);
238
- }
239
- for (const candidate of ICON_CANDIDATES) {
240
- if (files.has(candidate)) {
241
- return `https://huggingface.co/spaces/${spaceId}/resolve/main/${candidate}`;
242
- }
243
- }
244
- return null;
245
- }
246
-
247
- // Serialised LLM batch concurrency: we want at most one
248
- // categorization sweep running at a time, regardless of how many
249
- // /api/js-apps requests come in. The flag also prevents the
250
- // startup warm-up and an on-demand refresh from racing each other.
251
- let categorizationBatchRunning = false;
252
-
253
  // In-memory cache
254
  let appsCache = {
255
  data: null,
@@ -289,13 +53,6 @@ async function fetchAppsFromHF() {
289
  const author = spaceId.split('/')[0];
290
  const name = spaceId.split('/').pop();
291
 
292
- // Server-resolved icon URL. Looks for `public/icon.svg` or
293
- // `public/icon.png` via the `siblings` list returned by
294
- // `?full=true`. See `findIconUrl()` above for the rationale.
295
- // `null` when the author hasn't shipped one; clients fall
296
- // back to the front-matter emoji.
297
- const iconUrl = findIconUrl(spaceId, space.siblings);
298
-
299
  return {
300
  // Core fields (used by both website and desktop)
301
  id: spaceId,
@@ -304,8 +61,7 @@ async function fetchAppsFromHF() {
304
  url: `https://huggingface.co/spaces/${spaceId}`,
305
  source_kind: 'hf_space',
306
  isOfficial,
307
- iconUrl,
308
-
309
  // Extra metadata (desktop-compatible structure)
310
  extra: {
311
  id: spaceId,
@@ -329,86 +85,53 @@ async function fetchAppsFromHF() {
329
  };
330
  });
331
 
332
- console.log(`[Cache] Built ${allApps.length} raw app entries from HF.`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
- // Sort: official first, then by likes. Dedup is route-specific
335
- // and applied downstream (see `dedupGlobalApps` and `dedupJsApps`).
336
- allApps.sort((a, b) => {
337
  if (a.isOfficial !== b.isOfficial) {
338
  return a.isOfficial ? -1 : 1;
339
  }
340
  return (b.extra.likes || 0) - (a.extra.likes || 0);
341
  });
342
 
343
- return allApps;
344
  } catch (err) {
345
  console.error('[Cache] Error fetching apps:', err);
346
  throw err;
347
  }
348
  }
349
 
350
- /**
351
- * Pick a winner among Spaces sharing the same repo name. Forks
352
- * keep the upstream name (e.g. several `reachy_mini_conversation_app`
353
- * from different authors); we surface only one in the store to
354
- * avoid drowning the original under a dozen near-identical tiles.
355
- *
356
- * Priority: 1) official, 2) oldest (likely original), 3) most likes
357
- * as tiebreaker.
358
- */
359
- function dedupAppsByName(apps) {
360
- const deduped = new Map();
361
- for (const app of apps) {
362
- const key = app.name.toLowerCase();
363
- const existing = deduped.get(key);
364
- if (!existing) {
365
- deduped.set(key, app);
366
- continue;
367
- }
368
- if (app.isOfficial && !existing.isOfficial) {
369
- deduped.set(key, app);
370
- continue;
371
- }
372
- if (existing.isOfficial) continue;
373
- const appDate = app.extra?.createdAt ? new Date(app.extra.createdAt).getTime() : Infinity;
374
- const existingDate = existing.extra?.createdAt ? new Date(existing.extra.createdAt).getTime() : Infinity;
375
- if (appDate < existingDate) {
376
- deduped.set(key, app);
377
- } else if (appDate === existingDate && (app.extra?.likes || 0) > (existing.extra?.likes || 0)) {
378
- deduped.set(key, app);
379
- }
380
- }
381
- return [...deduped.values()];
382
- }
383
-
384
- /**
385
- * Dedup applied to the full `/api/apps` payload (Python + JS + others
386
- * mixed). Same-name collisions across SDKs collapse here too, by design:
387
- * the showcase site favours a clean catalog over completeness, and
388
- * SDK-aware variants of the same idea live as separate Spaces only
389
- * by accident in practice.
390
- */
391
- function dedupGlobalApps(apps) {
392
- return dedupAppsByName(apps);
393
- }
394
-
395
- /**
396
- * Dedup applied to the `/api/js-apps` route only. We restrict the
397
- * comparison to entries already filtered to the JS subset, so a JS
398
- * Space (e.g. `tfrere/emotions`) does not lose a name fight against
399
- * an unrelated Python Space sharing the same repo name (e.g.
400
- * `RemiFabre/emotions`). The mobile shell only sees JS apps anyway,
401
- * so confining dedup to that scope is what matches the user model.
402
- */
403
- function dedupJsApps(jsApps) {
404
- return dedupAppsByName(jsApps);
405
- }
406
-
407
- // Get raw apps with caching. Dedup is NOT applied here - each
408
- // route owns its own dedup policy (see `dedupGlobalApps` and
409
- // `dedupJsApps`) so they can disagree without paying for two
410
- // upstream fetches.
411
- async function getRawApps() {
412
  const now = Date.now();
413
 
414
  // Return cache if valid
@@ -430,7 +153,7 @@ async function getRawApps() {
430
  const apps = await fetchAppsFromHF();
431
  appsCache.data = apps;
432
  appsCache.lastFetch = now;
433
- console.log(`[Cache] Cache updated with ${apps.length} raw entries`);
434
  return apps;
435
  } catch (err) {
436
  // On error, return stale cache if available
@@ -447,12 +170,11 @@ async function getRawApps() {
447
  // API endpoint
448
  app.get('/api/apps', async (req, res) => {
449
  try {
450
- const raw = await getRawApps();
451
- const apps = dedupGlobalApps(raw);
452
- setCatalogCacheHeaders(res, appsCache.lastFetch);
453
  res.json({
454
  apps,
455
  cached: true,
 
456
  count: apps.length,
457
  });
458
  } catch (err) {
@@ -461,273 +183,6 @@ app.get('/api/apps', async (req, res) => {
461
  }
462
  });
463
 
464
- // =====================================================================
465
- // JS apps + LLM-inferred categories
466
- // =====================================================================
467
- //
468
- // `/api/js-apps` is a curated view on the JS-only subset:
469
- // 1. Filter on the `reachy_mini_js_app` tag (the mobile-embeddable subset).
470
- // 2. Dedup name collisions among JS apps only (`dedupJsApps`),
471
- // so a JS app does not get knocked out by a same-named Python
472
- // Space surfaced through `/api/apps`.
473
- // 3. Enrich each entry with `categories` + `categories_source`,
474
- // sourced from a persistent dataset cache (see categoryCache.js).
475
- //
476
- // Categories are inferred lazily by an LLM from each Space's
477
- // README. The first request after a cold start may see entries
478
- // with `categories: null` while the warmup batch is still in
479
- // flight; subsequent requests pick them up as the cache fills.
480
-
481
- /**
482
- * Pull the JS-app subset out of the raw apps cache, dedup it
483
- * within the JS scope, and fold in cached categories. Pure,
484
- * synchronous-ish (the only async call is to `getRawApps()` which
485
- * has its own cache).
486
- */
487
- async function getJsApps() {
488
- const raw = await getRawApps();
489
- const jsApps = raw.filter((a) => {
490
- const tags = a?.extra?.tags;
491
- return Array.isArray(tags) && tags.includes(JS_APP_TAG);
492
- });
493
- const deduped = dedupJsApps(jsApps);
494
-
495
- return deduped.map((app) => {
496
- const cached = categoryCache.get(app.id);
497
- return {
498
- ...app,
499
- categories: cached ? cached.categories : null,
500
- categories_source: cached ? 'inferred' : null,
501
- categorized_at: cached ? cached.categorizedAt : null,
502
- };
503
- });
504
- }
505
-
506
- /**
507
- * Run one classification pass over `jsApps`. Skips entries whose
508
- * cache is still fresh (same `lastModified`, same taxonomy).
509
- *
510
- * Serial on purpose: HF Inference Providers don't love bursts
511
- * from a single token, and total throughput on ~50 apps stays
512
- * well under a minute. We slip a small jitter between calls to
513
- * smooth the curve further.
514
- */
515
- async function runCategorizationBatch(jsApps) {
516
- if (categorizationBatchRunning) {
517
- console.log('[Categorize] Batch already running, skipping.');
518
- return;
519
- }
520
- if (!process.env.HF_TOKEN) {
521
- console.warn(
522
- '[Categorize] HF_TOKEN not set; skipping batch. Set it in .env ' +
523
- 'or the Space secrets to enable category inference.',
524
- );
525
- return;
526
- }
527
-
528
- const todo = jsApps.filter((app) =>
529
- categoryCache.needsCategorization(app.id, app?.extra?.lastModified),
530
- );
531
-
532
- if (todo.length === 0) {
533
- console.log(
534
- `[Categorize] All ${jsApps.length} JS apps are already categorized.`,
535
- );
536
- return;
537
- }
538
-
539
- categorizationBatchRunning = true;
540
- console.log(
541
- `[Categorize] Starting batch: ${todo.length}/${jsApps.length} app(s) need classification.`,
542
- );
543
-
544
- let success = 0;
545
- let failed = 0;
546
- let aborted = false;
547
-
548
- for (let i = 0; i < todo.length; i++) {
549
- const app = todo[i];
550
- const desc =
551
- app.description ||
552
- app.extra?.cardData?.short_description ||
553
- '';
554
- try {
555
- const slugs = await categorizeApp({
556
- spaceId: app.id,
557
- name: app.name,
558
- description: desc,
559
- });
560
- if (slugs == null) {
561
- failed++;
562
- console.log(
563
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: transient failure, will retry next pass`,
564
- );
565
- } else {
566
- categoryCache.set(app.id, {
567
- categories: slugs,
568
- lastModified: app.extra?.lastModified || null,
569
- });
570
- success++;
571
- console.log(
572
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: ${
573
- slugs.length ? slugs.join(', ') : '(no fit)'
574
- }`,
575
- );
576
- }
577
- } catch (err) {
578
- if (err instanceof HfTokenMissingError) {
579
- console.warn(
580
- '[Categorize] HF_TOKEN missing mid-batch; aborting cleanly.',
581
- );
582
- aborted = true;
583
- break;
584
- }
585
- failed++;
586
- console.warn(
587
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: error - ${err.message}`,
588
- );
589
- }
590
-
591
- // 250 ms cooldown between calls. Below this, the HF Provider
592
- // router occasionally rate-limits a hot token.
593
- await new Promise((resolve) => setTimeout(resolve, 250));
594
- }
595
-
596
- console.log(
597
- `[Categorize] Batch done: ${success} ok, ${failed} failed${aborted ? ' (aborted)' : ''}.`,
598
- );
599
- // Persist the new entries even if some failed - partial
600
- // progress is strictly better than none, and the failed
601
- // entries will be retried on the next pass.
602
- await categoryCache.flush();
603
-
604
- categorizationBatchRunning = false;
605
- }
606
-
607
- /**
608
- * Wrap the diagnostic snapshot for the API payload. Lets
609
- * consumers (mobile shell, website) decide whether to show
610
- * "loading categories..." or render chips immediately.
611
- */
612
- function buildCategorizationStats(jsApps) {
613
- let withCategories = 0;
614
- for (const app of jsApps) {
615
- if (app.categories && app.categories.length >= 0 && app.categories_source) {
616
- withCategories++;
617
- }
618
- }
619
- return {
620
- enabled: !!process.env.HF_TOKEN,
621
- total: jsApps.length,
622
- classified: withCategories,
623
- pending: jsApps.length - withCategories,
624
- inProgress: categorizationBatchRunning,
625
- // Authoritative taxonomy shipped alongside the apps so the
626
- // mobile shell (and any future client) doesn't have to mirror
627
- // the slug list by hand. Pairs with `taxonomyVersion` from
628
- // `categoryCache.stats()` so clients can detect drift between
629
- // the catalog payload and a stale on-device cache.
630
- taxonomy: getPublicTaxonomy(),
631
- ...categoryCache.stats(),
632
- };
633
- }
634
-
635
- app.get('/api/js-apps', async (req, res) => {
636
- try {
637
- const apps = await getJsApps();
638
-
639
- // Background top-up: if any entry is still uncategorized
640
- // (or a Space's lastModified moved since we last looked),
641
- // fire off a batch. We DO NOT await it - the response goes
642
- // out immediately with whatever the cache currently knows.
643
- const needsWork = apps.some(
644
- (a) =>
645
- !a.categories_source ||
646
- categoryCache.needsCategorization(a.id, a.extra?.lastModified),
647
- );
648
- if (needsWork) {
649
- // `void` to make it crystal clear we don't expect a value;
650
- // the batch logs its own progress.
651
- void runCategorizationBatch(apps).catch((err) => {
652
- console.error('[Categorize] Background batch crashed:', err);
653
- });
654
- }
655
-
656
- setCatalogCacheHeaders(res, appsCache.lastFetch);
657
- res.json({
658
- apps,
659
- cached: true,
660
- count: apps.length,
661
- categorization: buildCategorizationStats(apps),
662
- });
663
- } catch (err) {
664
- console.error('[API] /api/js-apps error:', err);
665
- res.status(500).json({ error: 'Failed to fetch JS apps' });
666
- }
667
- });
668
-
669
- // =====================================================================
670
- // Public taxonomy endpoint
671
- // =====================================================================
672
- //
673
- // Standalone read-only projection of the closed category taxonomy
674
- // (`server/categories.js`). Lets clients consume the slug list,
675
- // labels and emojis without paying the cost of a full apps fetch -
676
- // useful for early UI scaffolding (filter chips, empty states) and
677
- // for tooling that lints app metadata against the live taxonomy.
678
- //
679
- // `/api/js-apps` ALSO embeds the same payload under
680
- // `categorization.taxonomy`, so a mobile shell that fetches the
681
- // catalog never needs a second round-trip. This endpoint exists
682
- // for the "I just want the categories" use case.
683
- //
684
- // Cache headers: 5 minutes, same TTL as the catalog. The taxonomy
685
- // is stable across many catalog refreshes (it only moves when we
686
- // bump `TAXONOMY_VERSION`), but co-aligning the TTLs keeps the
687
- // reasoning simple - a client that polls both gets a coherent view.
688
- app.get('/api/categories', (_req, res) => {
689
- res.set('Cache-Control', 'public, max-age=300');
690
- const stats = categoryCache.stats();
691
- res.json({
692
- taxonomy: getPublicTaxonomy(),
693
- taxonomyVersion: stats.taxonomyVersion,
694
- });
695
- });
696
-
697
- // Manual trigger for a categorization sweep, useful when
698
- // hand-tuning the taxonomy or testing the LLM prompt without
699
- // waiting for the next /api/js-apps hit.
700
- app.post('/api/js-apps/refresh-categories', async (req, res) => {
701
- try {
702
- const apps = await getJsApps();
703
- void runCategorizationBatch(apps).catch((err) => {
704
- console.error('[Categorize] Manual batch crashed:', err);
705
- });
706
- res.json({
707
- ok: true,
708
- message: `Categorization batch kicked off for ${apps.length} JS apps.`,
709
- stats: buildCategorizationStats(apps),
710
- });
711
- } catch (err) {
712
- res.status(500).json({ error: 'Failed to trigger refresh' });
713
- }
714
- });
715
-
716
- // =====================================================================
717
- // OpenAI Realtime ephemeral keys
718
- // =====================================================================
719
- //
720
- // Per-user mint endpoint backing the Reachy Mini mobile shell's
721
- // voice conversation. The mobile client posts its HF Bearer token,
722
- // we validate it via `whoami-v2`, rate-limit per HF user, and
723
- // proxy a `POST /v1/realtime/sessions` to OpenAI with the master
724
- // `OPENAI_API_KEY` from this Space's secrets. The short-lived
725
- // `client_secret.value` is forwarded back to the client.
726
- //
727
- // See `server/openaiEphemeral.js` for the full design notes
728
- // (auth, caching, rate-limit shape, error mapping).
729
- app.post('/api/openai/ephemeral', mintEphemeralKeyHandler);
730
-
731
  // OAuth config endpoint - expose public OAuth variables to the frontend
732
  // (Docker Spaces don't auto-inject window.huggingface.variables like static Spaces)
733
  app.get('/api/oauth-config', (req, res) => {
@@ -758,7 +213,7 @@ app.get('/api/health', (req, res) => {
758
  app.post('/api/refresh', async (req, res) => {
759
  try {
760
  appsCache.lastFetch = null; // Invalidate cache
761
- const apps = await getRawApps();
762
  res.json({ success: true, count: apps.length });
763
  } catch (err) {
764
  res.status(500).json({ error: 'Failed to refresh cache' });
@@ -780,31 +235,8 @@ app.get('*', (req, res) => {
780
  async function warmCache() {
781
  console.log('[Startup] Pre-warming cache...');
782
  try {
783
- const apps = await getRawApps();
784
  console.log('[Startup] Cache warmed successfully');
785
-
786
- // Categorization warm-up: fire the JS-app batch in the
787
- // background so the first /api/js-apps caller doesn't
788
- // shoulder the cold-start cost. Order: load the dataset
789
- // cache first (cheap, one HTTP call), then run the batch
790
- // for stale entries only.
791
- void (async () => {
792
- try {
793
- await categoryCache.load();
794
- const jsApps = dedupJsApps(
795
- apps.filter((a) => {
796
- const tags = a?.extra?.tags;
797
- return Array.isArray(tags) && tags.includes(JS_APP_TAG);
798
- }),
799
- );
800
- console.log(
801
- `[Startup] Found ${jsApps.length} JS apps; checking categories...`,
802
- );
803
- await runCategorizationBatch(jsApps);
804
- } catch (err) {
805
- console.error('[Startup] Categorization warm-up failed:', err);
806
- }
807
- })();
808
  } catch (err) {
809
  console.error('[Startup] Failed to warm cache:', err);
810
  }
 
 
1
  import express from 'express';
 
2
  import path from 'path';
3
  import { fileURLToPath } from 'url';
4
 
 
 
 
 
 
5
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  const app = express();
8
  const PORT = process.env.PORT || 7860;
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  // Cache configuration
11
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
12
  const OFFICIAL_APP_LIST_URL = 'https://huggingface.co/datasets/pollen-robotics/reachy-mini-official-app-store/raw/main/app-list.json';
 
14
  // Note: HF API doesn't support pagination with filter=, so we use a high limit
15
  const HF_SPACES_LIMIT = 1000;
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  // In-memory cache
18
  let appsCache = {
19
  data: null,
 
53
  const author = spaceId.split('/')[0];
54
  const name = spaceId.split('/').pop();
55
 
 
 
 
 
 
 
 
56
  return {
57
  // Core fields (used by both website and desktop)
58
  id: spaceId,
 
61
  url: `https://huggingface.co/spaces/${spaceId}`,
62
  source_kind: 'hf_space',
63
  isOfficial,
64
+
 
65
  // Extra metadata (desktop-compatible structure)
66
  extra: {
67
  id: spaceId,
 
85
  };
86
  });
87
 
88
+ // Deduplicate by name: forks keep the same repo name (e.g. 4 spaces
89
+ // named "reachy_mini_conversation_app" from different authors).
90
+ // Priority: 1) official, 2) oldest (original), 3) most likes as tiebreaker.
91
+ const deduped = new Map();
92
+ for (const app of allApps) {
93
+ const key = app.name.toLowerCase();
94
+ const existing = deduped.get(key);
95
+ if (!existing) {
96
+ deduped.set(key, app);
97
+ continue;
98
+ }
99
+ // Official always wins
100
+ if (app.isOfficial && !existing.isOfficial) {
101
+ deduped.set(key, app);
102
+ continue;
103
+ }
104
+ if (existing.isOfficial) continue;
105
+ // Oldest wins (the original is created before its forks)
106
+ const appDate = app.extra.createdAt ? new Date(app.extra.createdAt).getTime() : Infinity;
107
+ const existingDate = existing.extra.createdAt ? new Date(existing.extra.createdAt).getTime() : Infinity;
108
+ if (appDate < existingDate) {
109
+ deduped.set(key, app);
110
+ } else if (appDate === existingDate && (app.extra.likes || 0) > (existing.extra.likes || 0)) {
111
+ deduped.set(key, app);
112
+ }
113
+ }
114
+ const uniqueApps = [...deduped.values()];
115
+
116
+ console.log(`[Cache] Deduplicated ${allApps.length} → ${uniqueApps.length} apps (removed ${allApps.length - uniqueApps.length} forks with duplicate names)`);
117
 
118
+ // Sort: official first, then by likes
119
+ uniqueApps.sort((a, b) => {
 
120
  if (a.isOfficial !== b.isOfficial) {
121
  return a.isOfficial ? -1 : 1;
122
  }
123
  return (b.extra.likes || 0) - (a.extra.likes || 0);
124
  });
125
 
126
+ return uniqueApps;
127
  } catch (err) {
128
  console.error('[Cache] Error fetching apps:', err);
129
  throw err;
130
  }
131
  }
132
 
133
+ // Get apps with caching
134
+ async function getApps() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  const now = Date.now();
136
 
137
  // Return cache if valid
 
153
  const apps = await fetchAppsFromHF();
154
  appsCache.data = apps;
155
  appsCache.lastFetch = now;
156
+ console.log(`[Cache] Cache updated with ${apps.length} apps`);
157
  return apps;
158
  } catch (err) {
159
  // On error, return stale cache if available
 
170
  // API endpoint
171
  app.get('/api/apps', async (req, res) => {
172
  try {
173
+ const apps = await getApps();
 
 
174
  res.json({
175
  apps,
176
  cached: true,
177
+ cacheAge: appsCache.lastFetch ? Math.round((Date.now() - appsCache.lastFetch) / 1000) : 0,
178
  count: apps.length,
179
  });
180
  } catch (err) {
 
183
  }
184
  });
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  // OAuth config endpoint - expose public OAuth variables to the frontend
187
  // (Docker Spaces don't auto-inject window.huggingface.variables like static Spaces)
188
  app.get('/api/oauth-config', (req, res) => {
 
213
  app.post('/api/refresh', async (req, res) => {
214
  try {
215
  appsCache.lastFetch = null; // Invalidate cache
216
+ const apps = await getApps();
217
  res.json({ success: true, count: apps.length });
218
  } catch (err) {
219
  res.status(500).json({ error: 'Failed to refresh cache' });
 
235
  async function warmCache() {
236
  console.log('[Startup] Pre-warming cache...');
237
  try {
238
+ await getApps();
239
  console.log('[Startup] Cache warmed successfully');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  } catch (err) {
241
  console.error('[Startup] Failed to warm cache:', err);
242
  }
server/openaiEphemeral.js DELETED
@@ -1,268 +0,0 @@
1
- /**
2
- * Mint per-user OpenAI Realtime ephemeral session keys.
3
- *
4
- * Why this module exists
5
- * ----------------------
6
- * The Reachy Mini mobile shell historically baked a long-lived
7
- * OpenAI API key into the bundle (`VITE_OPENAI_API_KEY`), which
8
- * violates OpenAI's terms of service and leaks the key the moment
9
- * anyone extracts the IPA/APK. This module is the server-side
10
- * replacement:
11
- *
12
- * - The master `OPENAI_API_KEY` stays in this Space's secrets,
13
- * never reachable by any client.
14
- * - The mobile shell asks for a short-lived (~1 minute)
15
- * `client_secret.value` per voice conversation, signed with
16
- * the user's Hugging Face token so we can identify + rate-limit
17
- * per HF user.
18
- * - Each ephemeral key is scoped to a single OpenAI Realtime
19
- * session, so a leak only loses ~60 seconds of model access.
20
- *
21
- * Wire format (matches OpenAI's Realtime API)
22
- * -------------------------------------------
23
- * POST /api/openai/ephemeral
24
- * Authorization: Bearer <hf_token>
25
- * Content-Type: application/json
26
- * Body: { "model"?: string, "voice"?: string }
27
- *
28
- * 200 -> the full payload from
29
- * `POST https://api.openai.com/v1/realtime/client_secrets`,
30
- * forwarded as-is. The client uses `payload.value` (the
31
- * `ek_…` ephemeral token) for the `POST /v1/realtime/calls`
32
- * WebRTC handshake.
33
- * 401 -> missing/invalid HF token
34
- * 429 -> per-user rate limit hit
35
- * 502 -> OpenAI upstream failed (key bad, model down, ...)
36
- * 503 -> OPENAI_API_KEY missing on the Space
37
- *
38
- * Why we trust HF for auth
39
- * ------------------------
40
- * The mobile shell already requires a Hugging Face sign-in to
41
- * pair with a Reachy Mini robot, so the HF token is a free
42
- * identity primitive: every legitimate caller already has one,
43
- * and HF can revoke it from their side. We resolve the token via
44
- * `whoami-v2` once per 5 minutes (cached) and use the returned
45
- * `name` as the rate-limit bucket key.
46
- */
47
-
48
- // GA endpoint. The legacy Beta endpoint
49
- // (`POST /v1/realtime/sessions`) was retired on 2026-05-07
50
- // alongside the `OpenAI-Beta: realtime=v1` header, and only
51
- // accepted preview models (`gpt-4o-realtime-preview-*`). The GA
52
- // endpoint takes a `session` envelope with a required `type`
53
- // discriminator and returns the ephemeral key at the top level
54
- // (`{ value, expires_at, session }`).
55
- const OPENAI_CLIENT_SECRETS_URL =
56
- 'https://api.openai.com/v1/realtime/client_secrets';
57
- const HF_WHOAMI_URL = 'https://huggingface.co/api/whoami-v2';
58
-
59
- // In-memory whoami cache. HF's whoami round-trip is ~150ms, and
60
- // caching it keeps the mint endpoint snappy without breaking
61
- // revocation in practice: HF's own token-revocation cache is
62
- // already eventually consistent, and our 5-minute staleness sits
63
- // well inside that.
64
- const whoamiCache = new Map();
65
- const WHOAMI_TTL_MS = 5 * 60 * 1000;
66
-
67
- // In-memory rate limiter. Per-user sliding window over 1 hour.
68
- // HF Spaces typically restart every deploy, so the limiter
69
- // implicitly resets then; that's acceptable for v1. If we ever
70
- // need durability or multi-replica fairness, swap the Map for a
71
- // shared KV (Redis, Upstash, ...) without changing the rest of
72
- // the module.
73
- const rateLimits = new Map();
74
- const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
75
-
76
- const DEFAULT_RATE_LIMIT_PER_HOUR = 60;
77
- // Match what the Reachy Mini mobile shell is built against today
78
- // (`features/conversation/engine/settings.ts:DEFAULT_MODEL` and
79
- // `DEFAULT_VOICE`). Bumping these requires coordinating with the
80
- // mobile client because the GA WebRTC handshake (`/v1/realtime/calls`)
81
- // negotiates the session shape against this same configuration.
82
- const DEFAULT_REALTIME_MODEL = 'gpt-realtime-2';
83
- const DEFAULT_REALTIME_VOICE = 'cedar';
84
-
85
- function getRateLimitMax() {
86
- const raw = process.env.OPENAI_EPHEMERAL_RATE_LIMIT_PER_HOUR;
87
- const parsed = raw ? Number.parseInt(raw, 10) : NaN;
88
- return Number.isFinite(parsed) && parsed > 0
89
- ? parsed
90
- : DEFAULT_RATE_LIMIT_PER_HOUR;
91
- }
92
-
93
- function getDefaultModel() {
94
- const raw = process.env.OPENAI_REALTIME_MODEL;
95
- return raw && raw.trim() !== '' ? raw.trim() : DEFAULT_REALTIME_MODEL;
96
- }
97
-
98
- class HttpError extends Error {
99
- constructor(status, message) {
100
- super(message);
101
- this.status = status;
102
- }
103
- }
104
-
105
- /**
106
- * Resolve `Bearer <token>` -> the HF user object, with a 5-minute
107
- * cache. Throws `HttpError(401)` on a rejected token so the route
108
- * can surface a clean 401 to the caller.
109
- */
110
- async function verifyHfToken(token) {
111
- const now = Date.now();
112
- const cached = whoamiCache.get(token);
113
- if (cached && cached.exp > now) return cached.user;
114
-
115
- let r;
116
- try {
117
- r = await fetch(HF_WHOAMI_URL, {
118
- headers: { Authorization: `Bearer ${token}` },
119
- });
120
- } catch (err) {
121
- // Network blip: surface a 502 so the client can retry. We
122
- // explicitly do NOT cache a failure, so a transient outage
123
- // doesn't lock the user out for 5 minutes.
124
- throw new HttpError(502, `hf whoami network error: ${err.message}`);
125
- }
126
-
127
- if (r.status === 401 || r.status === 403) {
128
- throw new HttpError(401, 'invalid hf token');
129
- }
130
- if (!r.ok) {
131
- throw new HttpError(502, `hf whoami returned ${r.status}`);
132
- }
133
-
134
- const user = await r.json().catch(() => null);
135
- // `name` is the canonical HF identifier across users + orgs;
136
- // `id` is the numeric backstop in case the schema ever shifts.
137
- if (
138
- !user ||
139
- (typeof user.name !== 'string' && typeof user.id !== 'string')
140
- ) {
141
- throw new HttpError(502, 'hf whoami returned malformed user');
142
- }
143
-
144
- whoamiCache.set(token, { user, exp: now + WHOAMI_TTL_MS });
145
- return user;
146
- }
147
-
148
- /**
149
- * Enforce the sliding-window rate limit for `userId`. Throws
150
- * `HttpError(429)` on overflow. Mutates `rateLimits` to record
151
- * the current mint timestamp.
152
- */
153
- function checkRateLimit(userId) {
154
- const now = Date.now();
155
- const windowStart = now - RATE_LIMIT_WINDOW_MS;
156
- const history = rateLimits.get(userId) || [];
157
- const recent = history.filter((t) => t > windowStart);
158
- if (recent.length >= getRateLimitMax()) {
159
- throw new HttpError(
160
- 429,
161
- `rate limit exceeded (${getRateLimitMax()}/hour)`,
162
- );
163
- }
164
- recent.push(now);
165
- rateLimits.set(userId, recent);
166
- }
167
-
168
- /**
169
- * Express handler for `POST /api/openai/ephemeral`. Stateless from
170
- * the caller's perspective: the client posts an HF Bearer token,
171
- * gets back the OpenAI Realtime session payload, uses it once.
172
- */
173
- export async function mintEphemeralKeyHandler(req, res) {
174
- try {
175
- if (!process.env.OPENAI_API_KEY) {
176
- return res
177
- .status(503)
178
- .json({ error: 'OPENAI_API_KEY not configured on this Space' });
179
- }
180
-
181
- const auth = req.headers.authorization || '';
182
- const match = auth.match(/^Bearer\s+(.+)$/i);
183
- if (!match) {
184
- return res.status(401).json({ error: 'missing bearer token' });
185
- }
186
- const hfToken = match[1].trim();
187
- if (!hfToken) {
188
- return res.status(401).json({ error: 'empty bearer token' });
189
- }
190
-
191
- const user = await verifyHfToken(hfToken);
192
- const userId = user.name || String(user.id);
193
- checkRateLimit(userId);
194
-
195
- // Caller may override model/voice for A/B tests, but the
196
- // defaults match what the mobile shell is built against. We
197
- // intentionally do NOT forward arbitrary fields from the
198
- // request body to OpenAI: only the two we validated.
199
- const body = req.body || {};
200
- const model =
201
- typeof body.model === 'string' && body.model.trim() !== ''
202
- ? body.model.trim()
203
- : getDefaultModel();
204
- const voice =
205
- typeof body.voice === 'string' && body.voice.trim() !== ''
206
- ? body.voice.trim()
207
- : DEFAULT_REALTIME_VOICE;
208
-
209
- // GA body shape: the session config sits under `session`, with
210
- // a required `type` discriminator (`"realtime"` for the voice
211
- // pipeline, `"transcription"` for transcription-only). The
212
- // mobile shell only needs `realtime`.
213
- const openaiBody = {
214
- session: {
215
- type: 'realtime',
216
- model,
217
- // The GA schema expects `audio.output.voice`. We mirror
218
- // the minimal shape: clients can still issue
219
- // `session.update` events over the data channel after
220
- // connect to tweak modalities, tools, instructions, etc.
221
- audio: {
222
- output: { voice },
223
- },
224
- },
225
- };
226
-
227
- let openaiRes;
228
- try {
229
- openaiRes = await fetch(OPENAI_CLIENT_SECRETS_URL, {
230
- method: 'POST',
231
- headers: {
232
- Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
233
- 'Content-Type': 'application/json',
234
- },
235
- body: JSON.stringify(openaiBody),
236
- });
237
- } catch (err) {
238
- console.error('[openai] mint network error:', err);
239
- return res.status(502).json({ error: 'openai network error' });
240
- }
241
-
242
- if (!openaiRes.ok) {
243
- const text = await openaiRes.text().catch(() => '');
244
- console.error(
245
- `[openai] mint failed for ${userId}: ${openaiRes.status} ${text}`,
246
- );
247
- return res.status(502).json({
248
- error: 'openai mint failed',
249
- upstreamStatus: openaiRes.status,
250
- });
251
- }
252
-
253
- const payload = await openaiRes.json();
254
- // We log the user id and the chosen model but NEVER the
255
- // client_secret. The secret stays on the wire to the
256
- // requesting client only.
257
- console.log(
258
- `[openai] minted ephemeral for ${userId} (model=${model}, voice=${voice})`,
259
- );
260
- return res.json(payload);
261
- } catch (err) {
262
- if (err instanceof HttpError) {
263
- return res.status(err.status).json({ error: err.message });
264
- }
265
- console.error('[openai] unexpected mint error:', err);
266
- return res.status(500).json({ error: 'internal error' });
267
- }
268
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/context/AppsContext.jsx CHANGED
@@ -105,17 +105,7 @@ export function AppsProvider({ children }) {
105
  if (response.ok) {
106
  const data = await response.json();
107
  allApps = data.apps;
108
- // Cache age moved from response body to the standard
109
- // `Age` HTTP header (RFC 7234 §5.1) so the body stays
110
- // byte-stable across same-cache-window requests and
111
- // ETag-based 304s work. `headers.get('age')` is null
112
- // on same-origin if the server didn't set it (older
113
- // deploy of the website Space): degrade gracefully to
114
- // a `?` rather than poisoning the log with `null`.
115
- const age = response.headers.get('age');
116
- console.log(
117
- `[AppsContext] Fetched ${allApps.length} apps from server cache (age: ${age ?? '?'}s)`,
118
- );
119
  } else {
120
  throw new Error('Server API not available');
121
  }
 
105
  if (response.ok) {
106
  const data = await response.json();
107
  allApps = data.apps;
108
+ console.log(`[AppsContext] Fetched ${allApps.length} apps from server cache (age: ${data.cacheAge}s)`);
 
 
 
 
 
 
 
 
 
 
109
  } else {
110
  throw new Error('Server API not available');
111
  }
src/pages/Buy.jsx CHANGED
@@ -39,10 +39,9 @@ const products = {
39
  name: 'Reachy Mini',
40
  tagline: 'The complete experience',
41
  price: 449,
42
- futurePrice: 499,
43
  badge: 'Wireless',
44
  badgeColor: '#0ea5e9',
45
- description: 'Self-contained robot with on-board compute. Works wirelessly or wired, perfect for standalone projects and demos. <strong>Ships in 60 days</strong>.',
46
  buyLink: 'https://buy.stripe.com/9B65kFfFlaKFbY34W873G03',
47
  image: '/assets/reachy-wireless.png',
48
  featured: true,
@@ -51,10 +50,9 @@ const products = {
51
  name: 'Reachy Mini Lite',
52
  tagline: 'Perfect to get started',
53
  price: 299,
54
- futurePrice: 399,
55
  badge: 'Lite',
56
  badgeColor: '#f59e0b',
57
- description: 'Connect to your computer via USB. Same expressive robot, powered by your machine. Ideal for development and learning. <strong>Ships in 30 days</strong>.',
58
  buyLink: 'https://buy.stripe.com/6oUfZj78P1a5e6b0FS73G02',
59
  image: '/assets/reachy-lite.png',
60
  featured: false,
@@ -70,7 +68,7 @@ const comparisonFeatures = [
70
  { name: 'Camera', wireless: 'Wide angle', lite: 'Wide angle' },
71
  { name: 'Microphones', wireless: '4 microphones array', lite: '4 microphones array' },
72
  { name: 'Speaker', wireless: '5W speaker', lite: '5W speaker' },
73
- { name: 'On-board Compute', wireless: 'Raspberry Pi CM 4 (16GB storage)', lite: false },
74
  { name: 'Accelerometer', wireless: 'Built-in IMU', lite: false },
75
  { name: 'Wi-Fi Connectivity', wireless: 'Wi-Fi', lite: false },
76
  { name: 'Standalone Mode', wireless: true, lite: false },
@@ -92,7 +90,7 @@ const boxContents = [
92
  const faqItems = [
93
  {
94
  question: 'What is the difference between Wireless and Lite?',
95
- answer: 'The Wireless version includes a Raspberry Pi CM 4 built-in, allowing it to run standalone without a computer. The Lite version connects to your Mac, Linux, or Windows computer via USB and uses your computer for processing. Both versions have the same mechanical design and audio/video capabilities.',
96
  },
97
  {
98
  question: 'How long does assembly take?',
@@ -328,26 +326,6 @@ function ProductCardsSection() {
328
  </Typography>
329
  </Box>
330
 
331
- {/* Upcoming price increase notice */}
332
- <Box
333
- sx={{
334
- mb: 3,
335
- px: 1.5,
336
- py: 1,
337
- borderRadius: 2,
338
- bgcolor: 'rgba(255, 149, 0, 0.08)',
339
- border: '1px solid',
340
- borderColor: 'rgba(255, 149, 0, 0.25)',
341
- }}
342
- >
343
- <Typography variant="body2" sx={{ color: 'text.secondary', lineHeight: 1.4 }}>
344
- Price rising to{' '}
345
- <Box component="span" sx={{ fontWeight: 700, color: '#FF9500' }}>
346
- ${product.futurePrice}
347
- </Box>{' '}
348
- soon — order today to lock in ${product.price}.
349
- </Typography>
350
- </Box>
351
  {/* Description */}
352
  <Typography
353
  variant="body2"
@@ -360,7 +338,7 @@ function ProductCardsSection() {
360
  <Stack spacing={1} sx={{ mb: 3 }}>
361
  {key === 'wireless' ? (
362
  <>
363
- <FeatureRow icon="✓" text="On-board Raspberry Pi CM 4" highlight />
364
  <FeatureRow icon="✓" text="Wi-Fi + USB connectivity" highlight />
365
  <FeatureRow icon="✓" text="Built-in IMU" highlight />
366
  </>
 
39
  name: 'Reachy Mini',
40
  tagline: 'The complete experience',
41
  price: 449,
 
42
  badge: 'Wireless',
43
  badgeColor: '#0ea5e9',
44
+ description: 'Self-contained robot with on-board compute. Works wirelessly or wired, perfect for standalone projects and demos. <strong>Ships in 90 days</strong>.',
45
  buyLink: 'https://buy.stripe.com/9B65kFfFlaKFbY34W873G03',
46
  image: '/assets/reachy-wireless.png',
47
  featured: true,
 
50
  name: 'Reachy Mini Lite',
51
  tagline: 'Perfect to get started',
52
  price: 299,
 
53
  badge: 'Lite',
54
  badgeColor: '#f59e0b',
55
+ description: 'Connect to your computer via USB. Same expressive robot, powered by your machine. Ideal for development and learning. <strong>Ships in 90 days</strong>.',
56
  buyLink: 'https://buy.stripe.com/6oUfZj78P1a5e6b0FS73G02',
57
  image: '/assets/reachy-lite.png',
58
  featured: false,
 
68
  { name: 'Camera', wireless: 'Wide angle', lite: 'Wide angle' },
69
  { name: 'Microphones', wireless: '4 microphones array', lite: '4 microphones array' },
70
  { name: 'Speaker', wireless: '5W speaker', lite: '5W speaker' },
71
+ { name: 'On-board Compute', wireless: 'Raspberry Pi 4 (16GB storage)', lite: false },
72
  { name: 'Accelerometer', wireless: 'Built-in IMU', lite: false },
73
  { name: 'Wi-Fi Connectivity', wireless: 'Wi-Fi', lite: false },
74
  { name: 'Standalone Mode', wireless: true, lite: false },
 
90
  const faqItems = [
91
  {
92
  question: 'What is the difference between Wireless and Lite?',
93
+ answer: 'The Wireless version includes a Raspberry Pi 4 built-in, allowing it to run standalone without a computer. The Lite version connects to your Mac, Linux, or Windows computer via USB and uses your computer for processing. Both versions have the same mechanical design and audio/video capabilities.',
94
  },
95
  {
96
  question: 'How long does assembly take?',
 
326
  </Typography>
327
  </Box>
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  {/* Description */}
330
  <Typography
331
  variant="body2"
 
338
  <Stack spacing={1} sx={{ mb: 3 }}>
339
  {key === 'wireless' ? (
340
  <>
341
+ <FeatureRow icon="✓" text="On-board Raspberry Pi 4" highlight />
342
  <FeatureRow icon="✓" text="Wi-Fi + USB connectivity" highlight />
343
  <FeatureRow icon="✓" text="Built-in IMU" highlight />
344
  </>
src/pages/Download.jsx CHANGED
@@ -18,7 +18,6 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
18
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
19
  import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
20
  import ExpandLessIcon from '@mui/icons-material/ExpandLess';
21
- import DesktopWindowsIcon from '@mui/icons-material/DesktopWindows';
22
 
23
  import Layout from '../components/Layout';
24
 
@@ -66,11 +65,6 @@ function detectPlatform() {
66
  return 'darwin-aarch64';
67
  }
68
 
69
- function isMobileDevice() {
70
- const ua = navigator.userAgent;
71
- return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
72
- }
73
-
74
  // Format date
75
  function formatDate(dateString) {
76
  const date = new Date(dateString);
@@ -180,9 +174,6 @@ function parseReleasePlatforms(assets) {
180
  const name = asset.name.toLowerCase();
181
  const url = asset.browser_download_url;
182
 
183
- // Skip signature files entirely
184
- if (name.endsWith('.sig')) return;
185
-
186
  // macOS Apple Silicon - prefer .dmg
187
  if (name.includes('arm64.dmg')) {
188
  platforms['darwin-aarch64'] = { url };
@@ -190,13 +181,13 @@ function parseReleasePlatforms(assets) {
190
  platforms['darwin-aarch64'] = { url };
191
  }
192
 
193
- // Windows - .msi
194
  if (name.endsWith('.msi')) {
195
  platforms['windows-x86_64'] = { url };
196
  }
197
 
198
  // Linux - .deb
199
- if (name.endsWith('.deb')) {
200
  platforms['linux-x86_64'] = { url };
201
  }
202
  });
@@ -321,13 +312,11 @@ export default function Download() {
321
  const [detectedPlatform, setDetectedPlatform] = useState(null);
322
  const [loading, setLoading] = useState(true);
323
  const [showAllReleases, setShowAllReleases] = useState(false);
324
- const [isMobile, setIsMobile] = useState(false);
325
 
326
  const [error, setError] = useState(null);
327
 
328
  useEffect(() => {
329
  setDetectedPlatform(detectPlatform());
330
- setIsMobile(isMobileDevice());
331
 
332
  // Fetch latest release info from GitHub API
333
  async function fetchReleases() {
@@ -532,97 +521,67 @@ export default function Download() {
532
  </Typography>
533
  </Stack>
534
 
535
- {/* Primary download button or mobile notice */}
536
- {isMobile ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  <Box
538
  sx={{
539
- mt: 2,
540
- p: 3,
541
- background: 'linear-gradient(135deg, rgba(255, 149, 0, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)',
542
- border: '1px solid rgba(255, 149, 0, 0.3)',
543
- borderRadius: 3,
544
  maxWidth: 500,
545
  mx: 'auto',
546
  }}
547
  >
548
- <DesktopWindowsIcon sx={{ fontSize: 40, color: 'rgba(255,255,255,0.5)', mb: 1.5 }} />
549
- <Typography
550
- variant="body1"
551
- sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 600, mb: 1 }}
552
- >
553
- Desktop only
554
- </Typography>
555
- <Typography
556
- variant="body2"
557
- sx={{ color: 'rgba(255,255,255,0.6)' }}
558
- >
559
- Reachy Mini Control is a desktop application available for macOS, Windows, and Linux. Please visit this page from a computer to download it.
560
- </Typography>
561
- </Box>
562
- ) : (
563
- <>
564
- <Button
565
- variant="contained"
566
- size="large"
567
- href={currentUrl}
568
- startIcon={<DownloadIcon />}
569
- sx={{
570
- px: 6,
571
- py: 2,
572
- fontSize: 17,
573
- fontWeight: 600,
574
- borderRadius: 3,
575
- background: 'linear-gradient(135deg, #FF9500 0%, #764ba2 100%)',
576
- boxShadow: '0 8px 32px rgba(255, 149, 0, 0.35)',
577
- transition: 'all 0.3s ease',
578
- '&:hover': {
579
- boxShadow: '0 12px 48px rgba(59, 130, 246, 0.5)',
580
- transform: 'translateY(-2px)',
581
- },
582
- }}
583
- >
584
- Download for {currentPlatform?.name}
585
- </Button>
586
-
587
  <Typography
588
  variant="body2"
589
  sx={{
590
- color: 'rgba(255,255,255,0.4)',
591
- mt: 2,
592
- fontSize: 13,
593
  }}
594
  >
595
- {currentPlatform?.subtitle} • {currentPlatform?.format?.replace('.', '').toUpperCase()} package
 
 
 
596
  </Typography>
597
-
598
- {/* Beta Warning for Windows and Linux */}
599
- {(detectedPlatform?.startsWith('windows') || detectedPlatform?.includes('linux')) && (
600
- <Box
601
- sx={{
602
- mt: 3,
603
- p: 2.5,
604
- background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)',
605
- border: '1px solid rgba(59, 130, 246, 0.3)',
606
- borderRadius: 2,
607
- maxWidth: 500,
608
- mx: 'auto',
609
- }}
610
- >
611
- <Typography
612
- variant="body2"
613
- sx={{
614
- color: 'rgba(255,255,255,0.8)',
615
- fontWeight: 500,
616
- }}
617
- >
618
- {detectedPlatform?.startsWith('windows')
619
- ? <>⚠️ Windows version is currently in Beta - installation requires <strong style={{ color: 'rgba(255,255,255,0.9)' }}>administrator privileges</strong>.</>
620
- : <>⚠️ Linux version is currently in Beta - please report any issues on <a href="https://github.com/pollen-robotics/reachy-mini-desktop-app/issues" target="_blank" rel="noopener noreferrer" style={{ color: '#3b82f6', textDecoration: 'underline' }}>GitHub</a> or <a href="https://discord.gg/HDrGY9eJHt" target="_blank" rel="noopener noreferrer" style={{ color: '#3b82f6', textDecoration: 'underline' }}>Discord</a>.</>
621
- }
622
- </Typography>
623
- </Box>
624
- )}
625
- </>
626
  )}
627
 
628
  {/* App screenshot */}
@@ -641,37 +600,35 @@ export default function Download() {
641
  />
642
  </Box>
643
 
644
- {/* All platforms - hidden on mobile */}
645
- {!isMobile && (
646
- <Box sx={{ mb: 8 }}>
647
- <Typography
648
- variant="overline"
649
- sx={{
650
- color: 'rgba(255,255,255,0.4)',
651
- display: 'block',
652
- textAlign: 'center',
653
- mb: 3,
654
- letterSpacing: 2,
655
- }}
656
- >
657
- Available for all platforms
658
- </Typography>
659
 
660
- <Grid container spacing={2}>
661
- {['darwin-aarch64', 'windows-x86_64', 'linux-x86_64'].map((key) => (
662
- <Grid size={{ xs: 12, sm: 4 }} key={key}>
663
- <PlatformCard
664
- platformKey={key}
665
- url={releaseData?.platforms[key]?.url}
666
- isActive={key === detectedPlatform}
667
- onClick={() => setDetectedPlatform(key)}
668
- />
669
- </Grid>
670
- ))}
671
- </Grid>
672
-
673
- </Box>
674
- )}
675
 
676
  {/* Features / What's included */}
677
  <Box
 
18
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
19
  import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
20
  import ExpandLessIcon from '@mui/icons-material/ExpandLess';
 
21
 
22
  import Layout from '../components/Layout';
23
 
 
65
  return 'darwin-aarch64';
66
  }
67
 
 
 
 
 
 
68
  // Format date
69
  function formatDate(dateString) {
70
  const date = new Date(dateString);
 
174
  const name = asset.name.toLowerCase();
175
  const url = asset.browser_download_url;
176
 
 
 
 
177
  // macOS Apple Silicon - prefer .dmg
178
  if (name.includes('arm64.dmg')) {
179
  platforms['darwin-aarch64'] = { url };
 
181
  platforms['darwin-aarch64'] = { url };
182
  }
183
 
184
+ // Windows - .msi (exclude .sig signature files)
185
  if (name.endsWith('.msi')) {
186
  platforms['windows-x86_64'] = { url };
187
  }
188
 
189
  // Linux - .deb
190
+ if (name.includes('amd64.deb')) {
191
  platforms['linux-x86_64'] = { url };
192
  }
193
  });
 
312
  const [detectedPlatform, setDetectedPlatform] = useState(null);
313
  const [loading, setLoading] = useState(true);
314
  const [showAllReleases, setShowAllReleases] = useState(false);
 
315
 
316
  const [error, setError] = useState(null);
317
 
318
  useEffect(() => {
319
  setDetectedPlatform(detectPlatform());
 
320
 
321
  // Fetch latest release info from GitHub API
322
  async function fetchReleases() {
 
521
  </Typography>
522
  </Stack>
523
 
524
+ {/* Primary download button */}
525
+ <Button
526
+ variant="contained"
527
+ size="large"
528
+ href={currentUrl}
529
+ startIcon={<DownloadIcon />}
530
+ sx={{
531
+ px: 6,
532
+ py: 2,
533
+ fontSize: 17,
534
+ fontWeight: 600,
535
+ borderRadius: 3,
536
+ background: 'linear-gradient(135deg, #FF9500 0%, #764ba2 100%)',
537
+ boxShadow: '0 8px 32px rgba(255, 149, 0, 0.35)',
538
+ transition: 'all 0.3s ease',
539
+ '&:hover': {
540
+ boxShadow: '0 12px 48px rgba(59, 130, 246, 0.5)',
541
+ transform: 'translateY(-2px)',
542
+ },
543
+ }}
544
+ >
545
+ Download for {currentPlatform?.name}
546
+ </Button>
547
+
548
+ <Typography
549
+ variant="body2"
550
+ sx={{
551
+ color: 'rgba(255,255,255,0.4)',
552
+ mt: 2,
553
+ fontSize: 13,
554
+ }}
555
+ >
556
+ {currentPlatform?.subtitle} • {currentPlatform?.format?.replace('.', '').toUpperCase()} package
557
+ </Typography>
558
+
559
+ {/* Beta Warning for Windows and Linux */}
560
+ {(detectedPlatform?.startsWith('windows') || detectedPlatform?.includes('linux')) && (
561
  <Box
562
  sx={{
563
+ mt: 3,
564
+ p: 2.5,
565
+ background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)',
566
+ border: '1px solid rgba(59, 130, 246, 0.3)',
567
+ borderRadius: 2,
568
  maxWidth: 500,
569
  mx: 'auto',
570
  }}
571
  >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  <Typography
573
  variant="body2"
574
  sx={{
575
+ color: 'rgba(255,255,255,0.8)',
576
+ fontWeight: 500,
 
577
  }}
578
  >
579
+ {detectedPlatform?.startsWith('windows')
580
+ ? <>⚠️ Windows version is currently in Beta — installation requires <strong style={{ color: 'rgba(255,255,255,0.9)' }}>administrator privileges</strong>.</>
581
+ : <>⚠️ Linux version is currently in Beta — please report any issues on <a href="https://github.com/pollen-robotics/reachy-mini-desktop-app/issues" target="_blank" rel="noopener noreferrer" style={{ color: '#3b82f6', textDecoration: 'underline' }}>GitHub</a> or <a href="https://discord.gg/HDrGY9eJHt" target="_blank" rel="noopener noreferrer" style={{ color: '#3b82f6', textDecoration: 'underline' }}>Discord</a>.</>
582
+ }
583
  </Typography>
584
+ </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  )}
586
 
587
  {/* App screenshot */}
 
600
  />
601
  </Box>
602
 
603
+ {/* All platforms */}
604
+ <Box sx={{ mb: 8 }}>
605
+ <Typography
606
+ variant="overline"
607
+ sx={{
608
+ color: 'rgba(255,255,255,0.4)',
609
+ display: 'block',
610
+ textAlign: 'center',
611
+ mb: 3,
612
+ letterSpacing: 2,
613
+ }}
614
+ >
615
+ Available for all platforms
616
+ </Typography>
 
617
 
618
+ <Grid container spacing={2}>
619
+ {['darwin-aarch64', 'windows-x86_64', 'linux-x86_64'].map((key) => (
620
+ <Grid size={{ xs: 12, sm: 4 }} key={key}>
621
+ <PlatformCard
622
+ platformKey={key}
623
+ url={releaseData?.platforms[key]?.url}
624
+ isActive={key === detectedPlatform}
625
+ onClick={() => setDetectedPlatform(key)}
626
+ />
627
+ </Grid>
628
+ ))}
629
+ </Grid>
630
+
631
+ </Box>
 
632
 
633
  {/* Features / What's included */}
634
  <Box
src/pages/GettingStarted.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react';
2
  import { Link as RouterLink, useLocation } from 'react-router-dom';
3
  import {
4
  Box,
@@ -18,7 +18,6 @@ import {
18
  } from '@mui/material';
19
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
20
  import DownloadIcon from '@mui/icons-material/Download';
21
- import DesktopWindowsIcon from '@mui/icons-material/DesktopWindows';
22
  import WifiIcon from '@mui/icons-material/Wifi';
23
  import UsbIcon from '@mui/icons-material/Usb';
24
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
@@ -142,11 +141,6 @@ function YouTubeEmbed({ videoId, title, version = 'wireless' }) {
142
  );
143
  }
144
 
145
- function isMobileDevice() {
146
- const ua = navigator.userAgent;
147
- return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
148
- }
149
-
150
  export default function GettingStarted() {
151
  const location = useLocation();
152
  const params = new URLSearchParams(location.search);
@@ -154,11 +148,6 @@ export default function GettingStarted() {
154
  const [version, setVersion] = useState(
155
  urlVersion === 'lite' ? 'lite' : 'wireless'
156
  );
157
- const [isMobile, setIsMobile] = useState(false);
158
-
159
- useEffect(() => {
160
- setIsMobile(isMobileDevice());
161
- }, []);
162
 
163
  return (
164
  <Layout transparentHeader>
@@ -339,45 +328,23 @@ export default function GettingStarted() {
339
  <Typography variant="caption" sx={{ display: 'block', mb: 2, color: 'warning.main' }}>
340
  Desktop App available for macOS (Apple Silicon), Windows & Linux (beta).
341
  </Typography>
342
- {isMobile ? (
343
- <Box
344
- sx={{
345
- p: 2,
346
- bgcolor: 'action.hover',
347
- borderRadius: 2,
348
- border: '1px solid',
349
- borderColor: 'divider',
350
- display: 'flex',
351
- alignItems: 'center',
352
- gap: 1.5,
353
- }}
354
- >
355
- <DesktopWindowsIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
356
- <Typography variant="body2" color="text.secondary">
357
- The desktop app can only be downloaded from a computer.
358
- </Typography>
359
- </Box>
360
- ) : (
361
- <>
362
- <Button
363
- variant="contained"
364
- component={RouterLink}
365
- to="/download"
366
- startIcon={<DownloadIcon/>}
367
- >
368
- Download Desktop App
369
- </Button>
370
-
371
- <Button
372
- variant="outlined"
373
- href="https://huggingface.co/docs/reachy_mini/SDK/installation"
374
- target="_blank"
375
- startIcon={<OpenInNewIcon/>}
376
- >
377
- Alternative: Python SDK
378
- </Button>
379
- </>
380
- )}
381
 
382
  </StepContent>
383
  </Step>
@@ -433,7 +400,7 @@ export default function GettingStarted() {
433
 
434
  <Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 600, mx: 'auto' }}>
435
  Follow our visual guide to put together your Reachy Mini.
436
- Most people finish in <strong>2-3 hours</strong> - our record is 43 minutes! 🏆
437
  </Typography>
438
 
439
  <Box
@@ -512,45 +479,23 @@ export default function GettingStarted() {
512
  <Typography variant="caption" sx={{ display: 'block', mb: 2, color: 'warning.main' }}>
513
  Desktop App available for macOS (Apple Silicon), Windows & Linux (beta).
514
  </Typography>
515
- {isMobile ? (
516
- <Box
517
- sx={{
518
- p: 2,
519
- bgcolor: 'action.hover',
520
- borderRadius: 2,
521
- border: '1px solid',
522
- borderColor: 'divider',
523
- display: 'flex',
524
- alignItems: 'center',
525
- gap: 1.5,
526
- }}
527
- >
528
- <DesktopWindowsIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
529
- <Typography variant="body2" color="text.secondary">
530
- The desktop app can only be downloaded from a computer.
531
- </Typography>
532
- </Box>
533
- ) : (
534
- <>
535
- <Button
536
- variant="contained"
537
- component={RouterLink}
538
- to="/download"
539
- startIcon={<DownloadIcon/>}
540
- >
541
- Download Desktop App
542
- </Button>
543
-
544
- <Button
545
- variant="outlined"
546
- href="https://huggingface.co/docs/reachy_mini/SDK/installation"
547
- target="_blank"
548
- startIcon={<OpenInNewIcon/>}
549
- >
550
- Alternative: Python SDK
551
- </Button>
552
- </>
553
- )}
554
  </StepContent>
555
  </Step>
556
  <Step active completed={false}>
 
1
+ import { useState } from 'react';
2
  import { Link as RouterLink, useLocation } from 'react-router-dom';
3
  import {
4
  Box,
 
18
  } from '@mui/material';
19
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
20
  import DownloadIcon from '@mui/icons-material/Download';
 
21
  import WifiIcon from '@mui/icons-material/Wifi';
22
  import UsbIcon from '@mui/icons-material/Usb';
23
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
 
141
  );
142
  }
143
 
 
 
 
 
 
144
  export default function GettingStarted() {
145
  const location = useLocation();
146
  const params = new URLSearchParams(location.search);
 
148
  const [version, setVersion] = useState(
149
  urlVersion === 'lite' ? 'lite' : 'wireless'
150
  );
 
 
 
 
 
151
 
152
  return (
153
  <Layout transparentHeader>
 
328
  <Typography variant="caption" sx={{ display: 'block', mb: 2, color: 'warning.main' }}>
329
  Desktop App available for macOS (Apple Silicon), Windows & Linux (beta).
330
  </Typography>
331
+ <Button
332
+ variant="contained"
333
+ component={RouterLink}
334
+ to="/download"
335
+ startIcon={<DownloadIcon/>}
336
+ >
337
+ Download Desktop App
338
+ </Button>
339
+
340
+ <Button
341
+ variant="outlined"
342
+ href="https://huggingface.co/docs/reachy_mini/SDK/installation"
343
+ target="_blank"
344
+ startIcon={<OpenInNewIcon/>}
345
+ >
346
+ Alternative: Python SDK
347
+ </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
 
349
  </StepContent>
350
  </Step>
 
400
 
401
  <Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 600, mx: 'auto' }}>
402
  Follow our visual guide to put together your Reachy Mini.
403
+ Most people finish in <strong>2-3 hours</strong> our record is 43 minutes! 🏆
404
  </Typography>
405
 
406
  <Box
 
479
  <Typography variant="caption" sx={{ display: 'block', mb: 2, color: 'warning.main' }}>
480
  Desktop App available for macOS (Apple Silicon), Windows & Linux (beta).
481
  </Typography>
482
+ <Button
483
+ variant="contained"
484
+ component={RouterLink}
485
+ to="/download"
486
+ startIcon={<DownloadIcon/>}
487
+ >
488
+ Download Desktop App
489
+ </Button>
490
+
491
+ <Button
492
+ variant="outlined"
493
+ href="https://huggingface.co/docs/reachy_mini/SDK/installation"
494
+ target="_blank"
495
+ startIcon={<OpenInNewIcon/>}
496
+ >
497
+ Alternative: Python SDK
498
+ </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  </StepContent>
500
  </Step>
501
  <Step active completed={false}>
src/pages/Home.jsx CHANGED
@@ -665,7 +665,7 @@ function ProductsSection() {
665
  sx={{ mb: 4, textAlign: "left", maxWidth: 280, mx: "auto" }}
666
  >
667
  {[
668
- "Raspberry Pi CM 4 on-board",
669
  "Wi-Fi + USB",
670
  "Camera, 4 mics, speaker",
671
  "Accelerometer",
 
665
  sx={{ mb: 4, textAlign: "left", maxWidth: 280, mx: "auto" }}
666
  >
667
  {[
668
+ "Raspberry Pi 4 on-board",
669
  "Wi-Fi + USB",
670
  "Camera, 4 mics, speaker",
671
  "Accelerometer",
yarn.lock CHANGED
@@ -1031,7 +1031,7 @@ browserslist@^4.24.0:
1031
  node-releases "^2.0.27"
1032
  update-browserslist-db "^1.2.0"
1033
 
1034
- bytes@3.1.2, bytes@~3.1.2:
1035
  version "3.1.2"
1036
  resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz"
1037
  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
@@ -1124,26 +1124,6 @@ comma-separated-tokens@^2.0.0:
1124
  resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz"
1125
  integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
1126
 
1127
- compressible@~2.0.18:
1128
- version "2.0.18"
1129
- resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
1130
- integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==
1131
- dependencies:
1132
- mime-db ">= 1.43.0 < 2"
1133
-
1134
- compression@^1.8.1:
1135
- version "1.8.1"
1136
- resolved "https://registry.yarnpkg.com/compression/-/compression-1.8.1.tgz#4a45d909ac16509195a9a28bd91094889c180d79"
1137
- integrity sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==
1138
- dependencies:
1139
- bytes "3.1.2"
1140
- compressible "~2.0.18"
1141
- debug "2.6.9"
1142
- negotiator "~0.6.4"
1143
- on-headers "~1.1.0"
1144
- safe-buffer "5.2.1"
1145
- vary "~1.1.2"
1146
-
1147
  concat-map@0.0.1:
1148
  version "0.0.1"
1149
  resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
@@ -2471,11 +2451,6 @@ mime-db@1.52.0:
2471
  resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
2472
  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
2473
 
2474
- "mime-db@>= 1.43.0 < 2":
2475
- version "1.54.0"
2476
- resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5"
2477
- integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==
2478
-
2479
  mime-types@~2.1.24, mime-types@~2.1.34:
2480
  version "2.1.35"
2481
  resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz"
@@ -2532,11 +2507,6 @@ negotiator@0.6.3:
2532
  resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz"
2533
  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
2534
 
2535
- negotiator@~0.6.4:
2536
- version "0.6.4"
2537
- resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7"
2538
- integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==
2539
-
2540
  node-releases@^2.0.27:
2541
  version "2.0.27"
2542
  resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz"
@@ -2559,11 +2529,6 @@ on-finished@~2.4.1:
2559
  dependencies:
2560
  ee-first "1.1.1"
2561
 
2562
- on-headers@~1.1.0:
2563
- version "1.1.0"
2564
- resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.1.0.tgz#59da4f91c45f5f989c6e4bcedc5a3b0aed70ff65"
2565
- integrity sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==
2566
-
2567
  optionator@^0.9.3:
2568
  version "0.9.4"
2569
  resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz"
 
1031
  node-releases "^2.0.27"
1032
  update-browserslist-db "^1.2.0"
1033
 
1034
+ bytes@~3.1.2:
1035
  version "3.1.2"
1036
  resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz"
1037
  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
 
1124
  resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz"
1125
  integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
1126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1127
  concat-map@0.0.1:
1128
  version "0.0.1"
1129
  resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
 
2451
  resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
2452
  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
2453
 
 
 
 
 
 
2454
  mime-types@~2.1.24, mime-types@~2.1.34:
2455
  version "2.1.35"
2456
  resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz"
 
2507
  resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz"
2508
  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
2509
 
 
 
 
 
 
2510
  node-releases@^2.0.27:
2511
  version "2.0.27"
2512
  resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz"
 
2529
  dependencies:
2530
  ee-first "1.1.1"
2531
 
 
 
 
 
 
2532
  optionator@^0.9.3:
2533
  version "0.9.4"
2534
  resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz"