.env.example DELETED
@@ -1,35 +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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,189 +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
- * Render the taxonomy as a bulleted list for the LLM prompt.
158
- * Format mirrors what the model is asked to output (slug first)
159
- * to nudge it towards copying the exact string back.
160
- */
161
- export function buildLlmCategoryList() {
162
- return CATEGORIES.map((c) => `- ${c.slug}: ${c.description}`).join('\n');
163
- }
164
-
165
- /**
166
- * Sanitize a raw LLM-returned list of slugs:
167
- * - drop non-strings
168
- * - lowercase + trim
169
- * - drop unknown slugs (hallucinations)
170
- * - dedupe while preserving order (the model orders by relevance)
171
- * - cap to MAX_CATEGORIES
172
- *
173
- * Returns a fresh array; never mutates input.
174
- */
175
- export function sanitizeSlugs(raw, maxCategories = 3) {
176
- if (!Array.isArray(raw)) return [];
177
- const seen = new Set();
178
- const out = [];
179
- for (const v of raw) {
180
- if (typeof v !== 'string') continue;
181
- const slug = v.trim().toLowerCase();
182
- if (!slug || seen.has(slug)) continue;
183
- if (!ALLOWED_SLUGS.has(slug)) continue;
184
- seen.add(slug);
185
- out.push(slug);
186
- if (out.length >= maxCategories) break;
187
- }
188
- return out;
189
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,42 +1,9 @@
1
  import express from 'express';
2
- import { existsSync, readFileSync } from 'fs';
3
  import path from 'path';
4
  import { fileURLToPath } from 'url';
5
 
6
- import { categorizeApp, HfTokenMissingError } from './categorize.js';
7
- import { categoryCache } from './categoryCache.js';
8
-
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
 
11
- // Load `.env` from the repo root in dev. In production (HF Space)
12
- // the platform already injects the secrets as env vars, so this
13
- // loader silently no-ops. We avoid the `dotenv` dep on purpose -
14
- // the format is trivial, and reproducing it inline keeps the
15
- // runtime closure tiny.
16
- (function loadDotenv() {
17
- try {
18
- const envPath = path.join(__dirname, '..', '.env');
19
- if (!existsSync(envPath)) return;
20
- const text = readFileSync(envPath, 'utf8');
21
- for (const line of text.split(/\r?\n/)) {
22
- const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/i);
23
- if (!m) continue;
24
- const [, key, raw] = m;
25
- let value = raw;
26
- if (
27
- (value.startsWith('"') && value.endsWith('"')) ||
28
- (value.startsWith("'") && value.endsWith("'"))
29
- ) {
30
- value = value.slice(1, -1);
31
- }
32
- // Existing env wins (so `HF_TOKEN=foo node …` overrides .env).
33
- if (process.env[key] === undefined) process.env[key] = value;
34
- }
35
- } catch {
36
- /* best-effort - missing or malformed .env never blocks boot */
37
- }
38
- })();
39
-
40
  const app = express();
41
  const PORT = process.env.PORT || 7860;
42
 
@@ -47,77 +14,6 @@ const HF_SPACES_API = 'https://huggingface.co/api/spaces';
47
  // Note: HF API doesn't support pagination with filter=, so we use a high limit
48
  const HF_SPACES_LIMIT = 1000;
49
 
50
- // Tag that gates the JS-only subset surfaced by /api/js-apps and
51
- // fed to the LLM categorizer. Mirrors the filter the mobile shell
52
- // applies today client-side; the route lets us retire that filter
53
- // from the mobile codebase down the line.
54
- const JS_APP_TAG = 'reachy_mini_js_app';
55
-
56
- // =====================================================================
57
- // App icon convention
58
- // =====================================================================
59
- //
60
- // Convention: an app MAY commit `icon.svg` (preferred) or
61
- // `icon.png` at the root of its HF Space repository. When present,
62
- // the mobile shell + desktop store render it as the app glyph
63
- // instead of the front-matter `emoji:` codepoint.
64
- //
65
- // We resolve the icon ONCE at indexing time (here) rather than
66
- // probing per-client because:
67
- // 1. We already pull `siblings` from `?full=true` (one cheap
68
- // hub call returns the file list for every app), so the
69
- // lookup is a pure JS filter, no extra network.
70
- // 2. Clients see a single field (`iconUrl`) in the payload and
71
- // don't have to know about HF resolve URLs, LFS pointers,
72
- // or the candidate-order race ("SVG wins if both exist").
73
- // 3. The HF API caps probes at ~hub side; doing it server-side
74
- // keeps fanout under a 5-minute TTL behind ONE token, instead
75
- // of every mobile shell hammering `huggingface.co/resolve/`
76
- // to discover icons.
77
- //
78
- // Resolution order: `icon.svg` → `icon.png`. SVG first because the
79
- // same asset scales cleanly across every mount point (small rail
80
- // tile, larger pinned tile, iframe header) from a single file.
81
- // Extra formats can be added to `ICON_CANDIDATES` if needed; order
82
- // matters - the first match wins.
83
- const ICON_CANDIDATES = ['icon.svg', 'icon.png'];
84
-
85
- /**
86
- * Look for a standard app icon file at the root of the Space.
87
- * Returns the absolute HF resolve URL when found, `null` otherwise.
88
- *
89
- * We hit `resolve/main/` (not `raw/main/`) so:
90
- * - LFS pointers follow transparently (large PNGs work).
91
- * - `Content-Type` comes from the extension, which `<img>` needs.
92
- * - The URL is cacheable cross-session by the browser, so
93
- * repeated mounts of the same app glyph don't re-fetch.
94
- */
95
- function findIconUrl(spaceId, siblings) {
96
- if (!spaceId || !Array.isArray(siblings)) return null;
97
- // Build a Set of root-level filenames for O(1) candidate
98
- // lookups. HF returns `siblings` as `[{ rfilename: "path/in/repo" }, ...]`,
99
- // so we filter to repo-root (no slash) before testing.
100
- const rootFiles = new Set();
101
- for (const s of siblings) {
102
- const name = s && typeof s.rfilename === 'string' ? s.rfilename : null;
103
- if (!name) continue;
104
- if (name.includes('/')) continue;
105
- rootFiles.add(name);
106
- }
107
- for (const candidate of ICON_CANDIDATES) {
108
- if (rootFiles.has(candidate)) {
109
- return `https://huggingface.co/spaces/${spaceId}/resolve/main/${candidate}`;
110
- }
111
- }
112
- return null;
113
- }
114
-
115
- // Serialised LLM batch concurrency: we want at most one
116
- // categorization sweep running at a time, regardless of how many
117
- // /api/js-apps requests come in. The flag also prevents the
118
- // startup warm-up and an on-demand refresh from racing each other.
119
- let categorizationBatchRunning = false;
120
-
121
  // In-memory cache
122
  let appsCache = {
123
  data: null,
@@ -157,13 +53,6 @@ async function fetchAppsFromHF() {
157
  const author = spaceId.split('/')[0];
158
  const name = spaceId.split('/').pop();
159
 
160
- // Server-resolved icon URL. Looks for `icon.svg` or `icon.png`
161
- // at the repo root via the `siblings` list returned by
162
- // `?full=true`. See `findIconUrl()` above for the rationale.
163
- // `null` when the author hasn't shipped one; clients fall
164
- // back to the front-matter emoji.
165
- const iconUrl = findIconUrl(spaceId, space.siblings);
166
-
167
  return {
168
  // Core fields (used by both website and desktop)
169
  id: spaceId,
@@ -172,8 +61,7 @@ async function fetchAppsFromHF() {
172
  url: `https://huggingface.co/spaces/${spaceId}`,
173
  source_kind: 'hf_space',
174
  isOfficial,
175
- iconUrl,
176
-
177
  // Extra metadata (desktop-compatible structure)
178
  extra: {
179
  id: spaceId,
@@ -295,221 +183,6 @@ app.get('/api/apps', async (req, res) => {
295
  }
296
  });
297
 
298
- // =====================================================================
299
- // JS apps + LLM-inferred categories
300
- // =====================================================================
301
- //
302
- // `/api/js-apps` is a curated view on top of `/api/apps`:
303
- // 1. Filter on the `reachy_mini_js_app` tag (the mobile-embeddable subset).
304
- // 2. Enrich each entry with `categories` + `categories_source`,
305
- // sourced from a persistent dataset cache (see categoryCache.js).
306
- //
307
- // Categories are inferred lazily by an LLM from each Space's
308
- // README. The first request after a cold start may see entries
309
- // with `categories: null` while the warmup batch is still in
310
- // flight; subsequent requests pick them up as the cache fills.
311
-
312
- /**
313
- * Pull the JS-app subset out of the global apps cache and fold
314
- * in cached categories. Pure, synchronous-ish (the only async
315
- * call is to the upstream `getApps()` which has its own cache).
316
- */
317
- async function getJsApps() {
318
- const apps = await getApps();
319
- const jsApps = apps.filter((a) => {
320
- const tags = a?.extra?.tags;
321
- return Array.isArray(tags) && tags.includes(JS_APP_TAG);
322
- });
323
-
324
- return jsApps.map((app) => {
325
- const cached = categoryCache.get(app.id);
326
- return {
327
- ...app,
328
- categories: cached ? cached.categories : null,
329
- categories_source: cached ? 'inferred' : null,
330
- categorized_at: cached ? cached.categorizedAt : null,
331
- };
332
- });
333
- }
334
-
335
- /**
336
- * Run one classification pass over `jsApps`. Skips entries whose
337
- * cache is still fresh (same `lastModified`, same taxonomy).
338
- *
339
- * Serial on purpose: HF Inference Providers don't love bursts
340
- * from a single token, and total throughput on ~50 apps stays
341
- * well under a minute. We slip a small jitter between calls to
342
- * smooth the curve further.
343
- */
344
- async function runCategorizationBatch(jsApps) {
345
- if (categorizationBatchRunning) {
346
- console.log('[Categorize] Batch already running, skipping.');
347
- return;
348
- }
349
- if (!process.env.HF_TOKEN) {
350
- console.warn(
351
- '[Categorize] HF_TOKEN not set; skipping batch. Set it in .env ' +
352
- 'or the Space secrets to enable category inference.',
353
- );
354
- return;
355
- }
356
-
357
- const todo = jsApps.filter((app) =>
358
- categoryCache.needsCategorization(app.id, app?.extra?.lastModified),
359
- );
360
-
361
- if (todo.length === 0) {
362
- console.log(
363
- `[Categorize] All ${jsApps.length} JS apps are already categorized.`,
364
- );
365
- return;
366
- }
367
-
368
- categorizationBatchRunning = true;
369
- console.log(
370
- `[Categorize] Starting batch: ${todo.length}/${jsApps.length} app(s) need classification.`,
371
- );
372
-
373
- let success = 0;
374
- let failed = 0;
375
- let aborted = false;
376
-
377
- for (let i = 0; i < todo.length; i++) {
378
- const app = todo[i];
379
- const desc =
380
- app.description ||
381
- app.extra?.cardData?.short_description ||
382
- '';
383
- try {
384
- const slugs = await categorizeApp({
385
- spaceId: app.id,
386
- name: app.name,
387
- description: desc,
388
- });
389
- if (slugs == null) {
390
- failed++;
391
- console.log(
392
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: transient failure, will retry next pass`,
393
- );
394
- } else {
395
- categoryCache.set(app.id, {
396
- categories: slugs,
397
- lastModified: app.extra?.lastModified || null,
398
- });
399
- success++;
400
- console.log(
401
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: ${
402
- slugs.length ? slugs.join(', ') : '(no fit)'
403
- }`,
404
- );
405
- }
406
- } catch (err) {
407
- if (err instanceof HfTokenMissingError) {
408
- console.warn(
409
- '[Categorize] HF_TOKEN missing mid-batch; aborting cleanly.',
410
- );
411
- aborted = true;
412
- break;
413
- }
414
- failed++;
415
- console.warn(
416
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: error - ${err.message}`,
417
- );
418
- }
419
-
420
- // 250 ms cooldown between calls. Below this, the HF Provider
421
- // router occasionally rate-limits a hot token.
422
- await new Promise((resolve) => setTimeout(resolve, 250));
423
- }
424
-
425
- console.log(
426
- `[Categorize] Batch done: ${success} ok, ${failed} failed${aborted ? ' (aborted)' : ''}.`,
427
- );
428
- // Persist the new entries even if some failed - partial
429
- // progress is strictly better than none, and the failed
430
- // entries will be retried on the next pass.
431
- await categoryCache.flush();
432
-
433
- categorizationBatchRunning = false;
434
- }
435
-
436
- /**
437
- * Wrap the diagnostic snapshot for the API payload. Lets
438
- * consumers (mobile shell, website) decide whether to show
439
- * "loading categories..." or render chips immediately.
440
- */
441
- function buildCategorizationStats(jsApps) {
442
- let withCategories = 0;
443
- for (const app of jsApps) {
444
- if (app.categories && app.categories.length >= 0 && app.categories_source) {
445
- withCategories++;
446
- }
447
- }
448
- return {
449
- enabled: !!process.env.HF_TOKEN,
450
- total: jsApps.length,
451
- classified: withCategories,
452
- pending: jsApps.length - withCategories,
453
- inProgress: categorizationBatchRunning,
454
- ...categoryCache.stats(),
455
- };
456
- }
457
-
458
- app.get('/api/js-apps', async (req, res) => {
459
- try {
460
- const apps = await getJsApps();
461
-
462
- // Background top-up: if any entry is still uncategorized
463
- // (or a Space's lastModified moved since we last looked),
464
- // fire off a batch. We DO NOT await it - the response goes
465
- // out immediately with whatever the cache currently knows.
466
- const needsWork = apps.some(
467
- (a) =>
468
- !a.categories_source ||
469
- categoryCache.needsCategorization(a.id, a.extra?.lastModified),
470
- );
471
- if (needsWork) {
472
- // `void` to make it crystal clear we don't expect a value;
473
- // the batch logs its own progress.
474
- void runCategorizationBatch(apps).catch((err) => {
475
- console.error('[Categorize] Background batch crashed:', err);
476
- });
477
- }
478
-
479
- res.json({
480
- apps,
481
- cached: true,
482
- cacheAge: appsCache.lastFetch
483
- ? Math.round((Date.now() - appsCache.lastFetch) / 1000)
484
- : 0,
485
- count: apps.length,
486
- categorization: buildCategorizationStats(apps),
487
- });
488
- } catch (err) {
489
- console.error('[API] /api/js-apps error:', err);
490
- res.status(500).json({ error: 'Failed to fetch JS apps' });
491
- }
492
- });
493
-
494
- // Manual trigger for a categorization sweep, useful when
495
- // hand-tuning the taxonomy or testing the LLM prompt without
496
- // waiting for the next /api/js-apps hit.
497
- app.post('/api/js-apps/refresh-categories', async (req, res) => {
498
- try {
499
- const apps = await getJsApps();
500
- void runCategorizationBatch(apps).catch((err) => {
501
- console.error('[Categorize] Manual batch crashed:', err);
502
- });
503
- res.json({
504
- ok: true,
505
- message: `Categorization batch kicked off for ${apps.length} JS apps.`,
506
- stats: buildCategorizationStats(apps),
507
- });
508
- } catch (err) {
509
- res.status(500).json({ error: 'Failed to trigger refresh' });
510
- }
511
- });
512
-
513
  // OAuth config endpoint - expose public OAuth variables to the frontend
514
  // (Docker Spaces don't auto-inject window.huggingface.variables like static Spaces)
515
  app.get('/api/oauth-config', (req, res) => {
@@ -562,29 +235,8 @@ app.get('*', (req, res) => {
562
  async function warmCache() {
563
  console.log('[Startup] Pre-warming cache...');
564
  try {
565
- const apps = await getApps();
566
  console.log('[Startup] Cache warmed successfully');
567
-
568
- // Categorization warm-up: fire the JS-app batch in the
569
- // background so the first /api/js-apps caller doesn't
570
- // shoulder the cold-start cost. Order: load the dataset
571
- // cache first (cheap, one HTTP call), then run the batch
572
- // for stale entries only.
573
- void (async () => {
574
- try {
575
- await categoryCache.load();
576
- const jsApps = apps.filter((a) => {
577
- const tags = a?.extra?.tags;
578
- return Array.isArray(tags) && tags.includes(JS_APP_TAG);
579
- });
580
- console.log(
581
- `[Startup] Found ${jsApps.length} JS apps; checking categories...`,
582
- );
583
- await runCategorizationBatch(jsApps);
584
- } catch (err) {
585
- console.error('[Startup] Categorization warm-up failed:', err);
586
- }
587
- })();
588
  } catch (err) {
589
  console.error('[Startup] Failed to warm cache:', err);
590
  }
 
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
 
 
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,
 
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) => {
 
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
  }
src/pages/Buy.jsx CHANGED
@@ -41,7 +41,7 @@ const products = {
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 60 days</strong>.',
45
  buyLink: 'https://buy.stripe.com/9B65kFfFlaKFbY34W873G03',
46
  image: '/assets/reachy-wireless.png',
47
  featured: true,
@@ -52,7 +52,7 @@ const products = {
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 30 days</strong>.',
56
  buyLink: 'https://buy.stripe.com/6oUfZj78P1a5e6b0FS73G02',
57
  image: '/assets/reachy-lite.png',
58
  featured: false,
@@ -68,7 +68,7 @@ const comparisonFeatures = [
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 CM 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,7 +90,7 @@ const boxContents = [
90
  const faqItems = [
91
  {
92
  question: 'What is the difference between Wireless and Lite?',
93
- 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.',
94
  },
95
  {
96
  question: 'How long does assembly take?',
@@ -338,7 +338,7 @@ function ProductCardsSection() {
338
  <Stack spacing={1} sx={{ mb: 3 }}>
339
  {key === 'wireless' ? (
340
  <>
341
- <FeatureRow icon="✓" text="On-board Raspberry Pi CM 4" highlight />
342
  <FeatureRow icon="✓" text="Wi-Fi + USB connectivity" highlight />
343
  <FeatureRow icon="✓" text="Built-in IMU" highlight />
344
  </>
@@ -379,7 +379,7 @@ function ProductCardsSection() {
379
  variant="body1"
380
  sx={{ fontWeight: 600, color: 'text.primary' }}
381
  >
382
- Current Lead time: 30 days for Lite, 60 days for Wireless after purchase
383
  </Typography>
384
  <Typography
385
  variant="body2"
 
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,
 
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?',
 
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
  </>
 
379
  variant="body1"
380
  sx={{ fontWeight: 600, color: 'text.primary' }}
381
  >
382
+ Current Lead time: 90 days after purchase
383
  </Typography>
384
  <Typography
385
  variant="body2"
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",
@@ -763,7 +763,7 @@ function ProductsSection() {
763
  fontWeight: 600,
764
  }}
765
  >
766
- Current Lead time: 30 days for Lite, 60 days for Wireless after purchase
767
  </Typography>
768
  <Typography
769
  variant="body2"
 
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",
 
763
  fontWeight: 600,
764
  }}
765
  >
766
+ Current Lead time: 90 days after purchase
767
  </Typography>
768
  <Typography
769
  variant="body2"