Wiring an LLM into a 3D scene so a VRM character can hear you, talk back, and even follow you with her eyes sounds cool — but in practice it throws a chain of engineering problems at you: the API key must never reach the browser, Serverless functions have a hard 10-second ceiling, R3F's <Canvas> doesn't propagate React Context, and speech recognition is unreliable in some regions…
This isn't an API walkthrough. It follows the actual decision order I used while building firefly (a React + React Three Fiber 3D avatar web app, live at firefly.erishen.cn). For each piece I state the problem first, then why it was designed that way, with real code from the version running in production — not a generic template.
1. Stack and Architecture Overview
Start with the hard requirements before picking tools:
- Web-only, no desktop runtime;
- UI deeply coupled with the 3D scene: chat bubbles, input box, language switch, and a "back home" button all float above the canvas;
- LLM streaming must flow through React's state.
Final stack:
- Frontend: React 18 + Vite + React Three Fiber + drei +
@pixiv/three-vrm(VRM loading/driving) - Backend: a lightweight Node.js proxy (zero-dependency native
http/fetch) + Vercel Serverless Functions, responsible for hiding the LLM key and orchestrating tool calls; the license-protected model file is hosted on a separate private backend (see section 3), decoupled from the chat backend
A common misconception: many "R3F avatar" tutorials write the backend in Python/FastAPI. firefly's chat backend is pure Node — because Vercel's Node function signature is exactly the standard
(req, res), the SSE streaming proxy can be reused directly without spinning up a separate Python service for the LLM. The license-protected model asset is instead hosted independently on a private backend (see section 3), decoupled from the chat backend.
Data flow:
Browser (R3F canvas + React UI) │ same-origin /api/chat only ▼ Node proxy (proxy.mjs / Vercel api/chat.js) │ ① hide real LLM key ② intent prefetch + tool orchestration ▼ OpenAI-compatible model gateway (key held server-side)
2. Security Baseline: Keep the LLM Key Server-Side
The scariest failure mode for a chatbot: hard-code the API key in the frontend or bundle it, so anyone opening DevTools can steal it. firefly's rule is the browser only ever talks to the same-origin /api/chat; the real key lives only in a server-side .env (local) or platform env var (prod).
server/config.mjs centralizes config and prefers process.env (Vercel-injected), falling back to the project-root .env:
export const BASE = val('LLM_BASE_URL') || ''
export const KEY = val('LLM_API_KEY') || ''
export const MODEL = val('LLM_MODEL') || ''
// Deploy safety: BIND_HOST defaults to 127.0.0.1 (local only); in prod set
// ALLOWED_ORIGINS to the frontend domain (e.g. https://firefly.erishen.cn).
// Never use '*' — that lets any site abuse your key.
export const ALLOWED_ORIGINS = val('ALLOWED_ORIGINS') || ''
proxy.mjs also runs a connectivity self-check at startup and gives a friendly message if unconfigured. Key point: the key never enters the browser, the source, or git — .env is git-ignored.
3. VRM Loading and "Instant" Open
VRM is an open standard for humanoid 3D models. @pixiv/three-vrm converts a .vrm into a Three.js object. firefly's loader lives in src/components/AvatarVRM.jsx: register VRMLoaderPlugin on GLTFLoader, then in the parse callback disable three-vrm's built-in lookAt (we drive bones manually) and record the head / leftEye / rightEye standard bones.
The part that actually affects UX is load speed. The model is 14.5MB, so re-downloading every time is painful. Two optimizations:
- Progress-aware download (
readWithProgresscomputes percentage fromContent-Length, renders a loading bar); - Browser Cache API byte cache — cache the parsed model bytes by URL, so a refresh/revisit hits cache and opens instantly.
const cache = await caches.open(MODEL_CACHE_NAME)
const hit = await cache.match(url)
if (hit) { onProgress(100); return hit.arrayBuffer() } // cache hit → skip download
License-protected model file: the avatar's VRM license forbids redistribution, so it can't be bundled into the frontend or published with the repo. Instead it's hosted on a separate private backend service, where all authentication and distribution logic lives server-side and no credentials ever reach the client.
One-line principle: model hosting is decoupled from the frontend; authentication stays server-side and credentials never touch the client.
4. The "Alive" Feeling: Gaze, Mouth, Blink
What makes the character feel alive is per-frame bone and expression driving in useFrame. firefly's real implementation:
Gaze follows the pointer (head / both eyes use humanoid standard bones, smooth interpolation toward pointer position, while recording the rest pose so we don't overwrite it):
useFrame((state) => {
if (!vrm) return
vrm.update(delta) // drive spring bones (hair/skirt physics) + expression transitions
const head = headRef.current
if (head) {
const tx = state.pointer.x * 0.35 // head turns with mouse/touch
const ty = -state.pointer.y * 0.25
head.rotation.y += (baseHead.y + tx - head.rotation.y) * 0.1
head.rotation.x += (baseHead.x + ty - head.rotation.x) * 0.1
}
// eyes do the same (smaller coefficient) → "eyes follow you"
})
Mouth: while speaking (speakingRef is true), the mouth morph cycles a/i/u/e/o to a beat — a stylized lip-flap, not audio-accurate phoneme alignment. Without phoneme annotation data this still reads clearly as "she's talking".
Blink is similar: prefer expressionManager's blink, but fall back to driving the mesh's morphTargetInfluences directly (the underlying まばたき blink morph) when a VRM0.0 export ships an empty expression set. This "dual-path probe + fallback" makes blinking/talking work across differently-sourced VRM models.
5. Voice Pipeline: Input vs Output (Usable vs Region-Limited)
firefly's voice lives in src/hooks/useVoice.js, split into two independent paths:
5.1 Speech Input (STT) — unreliable in some regions
Uses the browser-native SpeechRecognition (Chrome/Edge). Its recognition backend is Google's speech service, which often can't be reached on restricted networks, so recognition fails. The UI degrades gracefully: if the API is absent it switches to text input, and recognition failures surface as a bubble error — text input is always the primary path.
5.2 Speech Output (TTS) — works offline
This is frequently misunderstood. firefly does speak aloud, using the browser-native speechSynthesis (system voices, available offline, no regional block). Before reading it strips emoji/symbols (keeping only text), and prefers a "Chinese female" voice with a slightly raised pitch to fit the "maid Xiao Fei" persona:
const u = new SpeechSynthesisUtterance(stripSpeechSymbols(text))
u.lang = lang
u.pitch = 1.2 // slightly higher, cute/young-girl feel
So the real situation is: she can be heard (TTS works offline) but may not hear you well (STT is region-limited). I also state this honestly in the UI so users don't think the button is broken.
6. The Hard Battle: Fitting "Two LLM Rounds + Tools" into Vercel's 10 Seconds
This is the most worthwhile part of firefly, and the root cause of the original repeated 504 / "no response" errors.
The avatar needs live capabilities like weather and a product list, implemented via OpenAI-compatible function calling: the model gets tools in round 1 and decides whether to call one → the server executes it → the result is fed back → a second round (without tools) produces the final natural-language answer. The frontend doesn't change.
The problem: Vercel Hobby Serverless functions have a hard 10-second ceiling; past that the platform SIGKILLs the function, which looks like a silent no-response ("asking about weather does nothing"). And "LLM1 + weather + LLM2" easily blows past 10s.
My fix is server-side intent prefetch (single-round optimization): the moment a request arrives, regex-check whether the user is asking about weather/products. If so, fetch the data up front on the server, inject it as system context, and drop the corresponding tool from round 1, so the model answers directly — collapsing the flow from "LLM1 + weather + LLM2" to "weather + LLM1" in a single round.
// chat.mjs: weather intent pre-check + server-side prefetch
const weatherIntent = WEATHER_INTENT_RE.test(lastUserMsg.content || '')
if (weatherIntent) {
const w = await fetchWeather(wArgs) // fetch ahead on the server
if (w.ok) {
weatherContext = `\n[System weather context, answer directly, do not call the weather tool] ${w.summary}`
round1Tools = TOOL_DEFS.filter(t => t.function.name !== 'get_weather') // drop the tool
}
}
Three more reinforcements: the upstream LLM call uses fetchWithTimeout with a 25s circuit breaker; vercel.json sets maxDuration: 60 and deploys to the nearer hkg1 region (effective on Pro; Hobby clamps to 10s but doesn't error); in direct-connect environments an undici Agent({ keepAlive: true }) reuses TLS to cut cold-start handshake overhead.
Weather/products themselves are memory-cached (10 min by lat/lng, 24 h for geocoding) so repeated demo questions are near-instant.
7. The i18n Trap: R3F Doesn't Propagate React Context
firefly is bilingual (zh / EN: UI text + conversation language following). The natural approach is a React Context I18nProvider, but there's a trap: R3F v8's <Canvas> uses its own reconciler and does not propagate outer React Context, so components inside the canvas (e.g. the loading layer) can't see the outer t().
The fix is a module-level store + useSyncExternalStore, so both in-canvas and out-of-canvas components read language from the same external store and re-render on switch:
// src/i18n/store.js: not dependent on React Context
const listeners = new Set()
export function setLang(lang) {
currentLang = lang
localStorage.setItem(STORAGE_KEY, lang)
listeners.forEach(fn => fn(currentLang)) // notify all subscribers (incl. in-canvas)
}
export function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) }
Language switch, chat bubbles, and the 3D loading layer all follow correctly, and the preference persists to localStorage.
8. Deployment and Build
firefly deploys on Vercel (not GitHub Pages). vercel.json:
{
"framework": "vite",
"buildCommand": "npm run build",
"outputDirectory": "dist",
"regions": ["hkg1"]
}
The frontend is the static dist; the backend is api/*.js Serverless Functions — which directly import and reuse server/chat.mjs's handleChat (standard (req, res) style, matching Vercel's Node function signature, no adaptation needed for streaming SSE).
Key build decisions:
- Disable Vercel's default body parsing (
api: { bodyParser: false }) and lethandleChatread the raw stream itself, so SSE streaming isn't broken; maxDuration: 60: Hobby clamps to 10s without error; on Pro this value takes effect and greatly relieves the two-round LLM timeout;- CORS whitelist:
ALLOWED_ORIGINSmust be the frontend domain, never*, or any site could abuse your LLM key; - Model hosted separately: the license-protected VRM isn't bundled into the frontend; it's hosted on a separate private backend with server-side authentication (see section 3).
9. Summary: What This Architecture Actually Solves
Looking back across the whole chain, what firefly really solves isn't "writing less Three.js" — it's plugging the easy-to-trip hazards ahead of time:
- Security: LLM key never leaves the server; CORS whitelist prevents key abuse, and model assets are authenticated by the private backend (see section 3);
- Performance/availability: model byte-cache for instant open, weather/product memory cache, intent prefetch collapses "two LLM rounds" into "one" to fit the Serverless 10s ceiling;
- Cross-renderer boundary: a module-level store bypasses R3F's no-Context-propagation trap, keeping i18n consistent inside and outside the canvas;
- Honest degradation: when speech input is region-limited it says so and falls back to text; TTS uses the browser native layer to stay usable everywhere.
The cost is R3F's abstraction overhead (virtual DOM diff + per-frame reconcile), but for a single-avatar scene that's entirely acceptable. If you're building a similar interactive 3D + LLM project, I hope this "in the order the pitfalls actually appeared" write-up saves you a few loops.
Try it live at
firefly.erishen.cn. firefly currently supports Chinese / English switching, with a one-click "Back to home" button in the top-right corner.
FAQ
Why can't components inside a React Three Fiber Canvas read the outer React Context?
Because R3F v8’s <Canvas> runs its own reconciler, which does not propagate Context from the outer React tree – components inside the canvas (such as the 3D loading layer) simply cannot see values from an outer Provider. firefly hit this exact wall with i18n. The fix is to drop Context entirely and use a module-level store plus useSyncExternalStore: the language state lives in a module variable in src/i18n/store.js, setLang() notifies every subscriber, and components both inside and outside the canvas read from the same external store so they re-render together. The preference is persisted to localStorage.
Vercel Serverless functions cap at 10 seconds – how do you fit "LLM + tool call + second LLM round" inside that?
The textbook function-calling flow is two rounds (first round with tools so the model decides whether to call one, server executes the tool, result is fed back, second round without tools produces the final answer), which easily blows past the Hobby plan’s hard 10s ceiling; the platform SIGKILLs the function and the user just sees no response. firefly’s answer is server-side intent prefetch: as soon as the request arrives, a regex checks whether the user is asking about weather or products; if so the server fetches that data upfront, injects it as system context, and removes the corresponding tool from the first-round tool set so the model can answer in a single round – collapsing “LLM1 + tool + LLM2” into “tool + LLM1”. Supporting hardening: a 25s circuit breaker on upstream requests, maxDuration: 60, deploying to the hkg1 region, and undici keepAlive for TLS reuse.
How do you keep the LLM API key out of the frontend?
The browser only ever talks to the same-origin /api/chat; the real key exists only on the server – in .env locally (which is gitignored) and in platform environment variables in production, with config resolution reading process.env first and falling back to .env. The key never enters the browser, the code, or git. The other rule you must not break is CORS: ALLOWED_ORIGINS has to be an explicit whitelist of your frontend domain and must never be *, otherwise any website can proxy through you and burn your key. BIND_HOST also defaults to 127.0.0.1.
How does a 14.5MB VRM model open instantly on reload?
Two layers. First, a download with real progress: readWithProgress computes a percentage from Content-Length and renders a loading bar, so there’s no blank-screen anxiety. Second, a browser Cache API byte cache: the model bytes are stored in caches keyed by URL, so on reload or re-entry a cache.match(url) hit returns the arrayBuffer directly and skips the download entirely. Separately, this VRM’s license is redistribution=disallow, so it is not bundled into the frontend or published with the repo – it’s served by a separate private backend with authentication on the server side and no credentials landing on the client.
How are the avatar's mouth movement and blinking driven – is it true phoneme-accurate lip sync?
No. The mouth is a stylized “mouth rhythm”: while speaking, the mouth morphs cycle through a/i/u/e/o on a beat, which conveys “she is talking” without any phoneme-aligned annotation data – it is not frame-accurate lip sync against the audio. Blinking uses a dual-probe with fallback: it prefers the expressionManager blink expression, and when a VRM0.0 export ships an empty expression group it falls back to driving the mesh’s morphTargetInfluences directly (the underlying blink morph), so VRM files from different sources all blink correctly. Gaze tracking is done in useFrame by smoothly interpolating the head and eye bones toward the pointer position, recording the initial bind pose so the original pose isn’t overwritten.
Do the browser's native speech APIs work in mainland China?
They split into two independent pipelines, and the honest summary is “she can be heard, but she may not hear you.” Speech output (TTS) uses the browser-native speechSynthesis with system voices – it works offline and is unaffected in China; before speaking, emoji and symbols are stripped, a Chinese female voice is preferred, and pitch is raised to 1.2 to match the persona. Speech input (STT) uses the browser-native SpeechRecognition, whose recognition backend is Google’s speech service, which is often unreachable on mainland networks and fails. So the UI degrades explicitly: if the API isn’t detected it switches to text input automatically, and recognition failures are surfaced in the chat bubble. Text input is always the primary path.
Source Navigation
src/components/Stage.jsx— VRM model loading and byte cachesrc/components/AvatarVRM.jsx— VRM parsing, gaze / mouth / blink drivingsrc/hooks/useVoice.js— speech input (STT) / output (TTS) pipeline and degradationsrc/i18n/store.js— module-level language store (bypassing R3F Context)server/chat.mjs—/api/chatstreaming proxy, tool orchestration, intent prefetchserver/config.mjs— config parsing and timeout circuit breakerserver/weather.mjs— server-side weather tool prefetchvercel.json— deployment region andmaxDurationconfig
Full source: https://github.com/erishen/firefly
Leave a reply