Créer des applications web
Ceci est le guide pratique pour implémenter un Agent en temps réel dans un navigateur : le modÚle de pont cÎté serveur, le protocole WebSocket, et le code Web Audio qui capture le micro et diffuse l'Agent sans interruption. Chaque exemple web de cette section est construit exactement de cette maniÚre ; le serveur customer_service est l'implémentation de référence.
Pourquoi un pont cÎté serveur
Un navigateur ne peut pas maintenir directement le WebSocket en temps réel du fournisseur :
- votre
OPENAI_API_KEY/GEMINI_API_KEYserait envoyé à chaque client, - les outils s'exécuteraient dans le navigateur, loin de vos données et informations d'identification,
- vous seriez lié au format de fil d'un seul fournisseur dans le code client.
Ainsi, le navigateur est un dispositif audio/vidéo léger et votre serveur Rust possÚde la session :
browser ââmic PCM16 + camera JPEG (base64 over your WS)âââ¶ Axum /ws
browser âââagent PCM16 + transcripts + tool eventsâââââââ IntegratedRealtimeRunner âââ¶ provider
La clé reste sur le serveur, les outils s'exécutent sur le serveur, et vous pouvez changer de fournisseur par connexion (/ws?provider=openai|gemini) sans toucher au client.
Le protocole WebSocket
Un minuscule protocole JSON utilise votre propre WebSocket. Navigateur â serveur :
type | Champs | Signification |
|---|---|---|
input_audio | audio (base64 PCM16) | Un morceau d'audio du micro |
video_frame | mime, data (base64) | Une image de caméra |
text | text | Un message de chat tapé |
hangup | â | Terminer la session |
Serveur â navigateur :
type | Champs | Rendu comme |
|---|---|---|
ready | provider, input_rate, output_rate | DĂ©bits audio nĂ©gociĂ©s â crĂ©ez vos AudioContext |
audio | audio (base64 PCM16) | Mettre en file d'attente pour une lecture sans interruption |
agent_transcript | delta | Ajouter Ă la bulle de l'agent |
user_transcript_delta | delta | Sous-titrage en direct de la parole de l'utilisateur |
user_transcript | text | Transcription finale de l'utilisateur |
user_speaking / user_stopped | â | Ătat VAD (piloter un indicateur de micro ; vider la lecture sur user_speaking pour l'interruption) |
tool | name, args | Un jeton "outil en cours d'exĂ©cutionâŠ" |
response_done | â | Tour terminĂ© |
error | message | Afficher l'erreur |
Ceci est un mappage fin, dĂ©fini par l'application, sur ServerEvent â voir
server_event_to_client_json ci-dessous.
Serveur : le pont Axum
Le gestionnaire construit un IntegratedRealtimeRunner, se connecte, envoie ready, puis
exĂ©cute deux boucles concurrentes â sortante (Ă©vĂ©nements en temps rĂ©el â navigateur) et
entrante (navigateur â session) â jointes avec tokio::select!.
async fn handle_ws(socket: WebSocket, provider: Provider) {
let session_id = uuid::Uuid::new_v4().to_string();
let (mut sender, mut receiver) = socket.split();
let runner = Arc::new(build_runner(provider, &session_id).await.unwrap());
runner.connect().await.unwrap();
// Negotiate audio rates to the browser BEFORE any audio flows.
let (input_rate, output_rate) = provider.audio_rates();
sender.send(Message::Text(json!({
"type": "ready", "provider": provider.name(),
"input_rate": input_rate, "output_rate": output_rate,
}).to_string().into())).await.ok();
// Outbound: realtime events â browser.
let out_runner = runner.clone();
let outbound = async move {
while let Some(event) = out_runner.next_event().await {
if let Ok(ev) = event {
if let Some(payload) = server_event_to_client_json(ev) {
if sender.send(Message::Text(payload.to_string().into())).await.is_err() { break; }
}
}
}
};
// Inbound: browser mic/camera/text â session.
let in_runner = runner.clone();
let inbound = async move {
while let Some(Ok(Message::Text(text))) = receiver.next().await {
match serde_json::from_str::<ClientMsg>(&text) {
Ok(ClientMsg::InputAudio { audio }) => { in_runner.send_audio(&audio).await.ok(); }
Ok(ClientMsg::VideoFrame { mime, data }) => { in_runner.send_video_frame(&mime, &data).await.ok(); }
Ok(ClientMsg::Text { text }) => {
if in_runner.send_text(&text).await.is_ok() { in_runner.create_response().await.ok(); }
}
Ok(ClientMsg::Hangup) => break,
_ => {}
}
}
};
tokio::select! { _ = outbound => {}, _ = inbound => {} }
runner.close().await.ok();
}
Notez les deux asymétries d'entrée que vous devez corriger :
- Le texte nécessite
create_response()â il n'y a pas de VAD pour dĂ©clencher une rĂ©ponse (voir Architecture: cycle de vie d'un tour). L'audio sous VAD du serveur ne le fait pas. - Les erreurs vidĂ©o ne sont pas fatales â enregistrez et continuez ; une trame perdue ne devrait pas interrompre l'appel.
Mappage des événements du serveur
La boucle de sortie convertit chaque ServerEvent agnostique au fournisseur en le JSON client compact ci-dessus, renvoyant None pour les événements ignorés par l'interface utilisateur :
fn server_event_to_client_json(event: ServerEvent) -> Option<serde_json::Value> {
match event {
ServerEvent::AudioDelta { delta, .. } =>
Some(json!({ "type": "audio", "audio": BASE64.encode(&delta) })),
ServerEvent::TranscriptDelta { delta, .. } =>
Some(json!({ "type": "agent_transcript", "delta": delta })),
ServerEvent::InputTranscriptDelta { delta, .. } =>
Some(json!({ "type": "user_transcript_delta", "delta": delta })),
ServerEvent::SpeechStarted { .. } => Some(json!({ "type": "user_speaking" })),
ServerEvent::FunctionCallDone { name, arguments, .. } =>
Some(json!({ "type": "tool", "name": name, "args": arguments })),
ServerEvent::ResponseDone { .. } => Some(json!({ "type": "response_done" })),
ServerEvent::Error { error, .. } => Some(json!({ "type": "error", "message": error.message })),
_ => None, // ServerEvent is #[non_exhaustive]
}
}
Navigateur : capture du micro en PCM16
Le fournisseur souhaite du PCM16 mono brut au input_rate négocié. Capturez avec un AudioContext à ce débit, sous-échantillonnez les échantillons flottants en 16 bits, encodez-les en base64, et envoyez-les :
let inputRate, outputRate;
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'ready') { inputRate = msg.input_rate; outputRate = msg.output_rate; startMic(); }
// âŠhandle audio / transcripts / tool / etc.
};
async function startMic() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const ctx = new AudioContext({ sampleRate: inputRate });
const src = ctx.createMediaStreamSource(stream);
const node = ctx.createScriptProcessor(4096, 1, 1); // or an AudioWorklet
node.onaudioprocess = (ev) => {
const f32 = ev.inputBuffer.getChannelData(0);
const pcm16 = new Int16Array(f32.length);
for (let i = 0; i < f32.length; i++) {
const s = Math.max(-1, Math.min(1, f32[i]));
pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
ws.send(JSON.stringify({ type: 'input_audio', audio: bytesToBase64(new Uint8Array(pcm16.buffer)) }));
};
src.connect(node); node.connect(ctx.destination);
}
Navigateur : lecture sans interruption + interruption
L'audio de l'agent arrive sous forme de flux de blocs PCM16 à output_rate. Décodez chaque bloc en flottant, planifiez-le en continu sur une lecture AudioContext, et maintenez un curseur en cours d'exécution afin que les blocs ne se chevauchent pas ou ne créent pas de lacunes :
const playCtx = new AudioContext({ sampleRate: outputRate });
let playHead = 0;
const sources = [];
function playChunk(base64) {
const bytes = base64ToBytes(base64);
const pcm16 = new Int16Array(bytes.buffer);
const buf = playCtx.createBuffer(1, pcm16.length, outputRate);
const ch = buf.getChannelData(0);
for (let i = 0; i < pcm16.length; i++) ch[i] = pcm16[i] / 0x8000;
const node = playCtx.createBufferSource();
node.buffer = buf;
node.connect(playCtx.destination);
const startAt = Math.max(playCtx.currentTime, playHead);
node.start(startAt);
playHead = startAt + buf.duration;
sources.push(node);
}
// Barge-in: when the user starts speaking, stop the agent immediately.
function flushPlayback() {
for (const n of sources) { try { n.stop(); } catch {} }
sources.length = 0;
playHead = 0;
}
Appelez flushPlayback() sur le message user_speaking afin que l'agent cesse de parler dĂšs que l'utilisateur l'interrompt â la sensation naturelle d'une vraie conversation.
Cadres de caméra
Voir Multimodal pour la boucle de capture du canevas â dessinez le <video> sur un canevas, toDataURL('image/jpeg', 0.6), supprimez le prĂ©fixe, et envoyez un message video_frame Ă une cadence appropriĂ©e au fournisseur (~700 ms Gemini, ~2.5 s OpenAI).
Une liste de contrĂŽle
- La clé et les outils résident sur le serveur, jamais dans le navigateur.
- Envoyer
readyavecinput_rate/output_rateavant l'audio ; construire desAudioContexts Ă ces dĂ©bits. - Capturer le micro en PCM16 mono Ă
input_rate. - Lire l'audio de l'agent sans interruption avec un curseur de planification Ă
output_rate. - Vider la lecture sur
user_speaking(barge-in). - Appeler
create_response()aprĂšssend_text(pas pour l'audio VAD). - Traiter les erreurs d'envoi de
video_framecomme non-fatales. - Armer
_ => {}lors de la correspondance avecServerEvent(c'est#[non_exhaustive]).
Suivant : Exemples â