构建 Web 应用
这是将实时 agent 放入浏览器中的实用指南:服务器端桥接模式,WebSocket 协议,以及捕获麦克风并无缝播放 agent 的 Web Audio 代码。本节中的每个 Web 示例都以此方式构建;customer_service 服务器是参考实现。
为什么选择服务器端桥接
浏览器不能直接持有提供商的实时 WebSocket:
- 您的
OPENAI_API_KEY/GEMINI_API_KEY将发送给每个客户端, - 工具将在浏览器中运行,远离您的数据和凭据,
- 您将被锁定在客户端代码中某个提供商的线缆格式。
因此,浏览器是一个轻量级音视频设备,您的 Rust 服务器拥有 session:
browser ──mic PCM16 + camera JPEG (base64 over your WS)──▶ Axum /ws
browser ◀──agent PCM16 + transcripts + tool events─────── IntegratedRealtimeRunner ──▶ provider
密钥保留在服务器上,工具在服务器上运行,您可以按连接切换提供商 (/ws?provider=openai|gemini),而无需触及客户端。
WebSocket 协议
一个微小的 JSON 协议承载在您自己的 WebSocket 上。浏览器 → 服务器:
type | 字段 | 含义 |
|---|---|---|
input_audio | audio (base64 PCM16) | 一段麦克风音频 |
video_frame | mime, data (base64) | 一个摄像头帧 |
text | text | 一条输入的聊天消息 |
hangup | — | 结束会话 |
服务器 → 浏览器:
type | 字段 | 渲染为 |
|---|---|---|
ready | provider, input_rate, output_rate | 协商的音频速率 — 创建您的 AudioContexts |
audio | audio (base64 PCM16) | 排队等待无缝播放 |
agent_transcript | delta | 追加到代理的气泡 |
user_transcript_delta | delta | 用户的实时语音字幕 |
user_transcript | text | 最终用户转录 |
user_speaking / user_stopped | — | VAD 状态 (驱动麦克风指示器;在 user_speaking 上刷新播放以进行插话) |
tool | name, args | 一个“正在运行工具…”的芯片 |
response_done | — | 回合结束 |
error | message | 显示错误 |
这是一个薄的、应用定义的映射,基于 ServerEvent — 参见
server_event_to_client_json 如下。
服务器:Axum 桥接
处理器构建一个 IntegratedRealtimeRunner,连接,发送 ready,然后
运行两个并发循环 — 出站(实时事件 → 浏览器)和
入站(浏览器 → 会话)— 与 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();
}
请注意必须正确处理的两个输入不对称性:
- 文本需要
create_response()— 没有 VAD 来触发回复(参见 Architecture: turn lifecycle)。服务器 VAD 下的音频则不需要。 - 视频错误是非致命的 — 记录并继续;丢帧不应 中断通话。
映射服务器事件
出站循环将每个与提供商无关的 ServerEvent 转换为紧凑的
客户端 JSON 如上,并为 UI 忽略的事件返回 None:
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]
}
}
浏览器:将麦克风捕获为 PCM16
提供商需要协商的 input_rate 处的 原始 PCM16 单声道。使用该速率的
AudioContext 进行捕获,将浮点样本下采样到 16 位,对其进行 base64
编码,然后发送:
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);
}
浏览器:无缝播放 + 打断
代理的音频以 PCM16 块流的形式到达 output_rate。将每个块解码为浮点数,在播放
AudioContext 上背靠背地调度,并保持一个运行游标,以使块不重叠或出现间隙:
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;
}
在 user_speaking 消息上调用 flushPlayback(),以便代理在用户打断的瞬间停止说话 — 真实对话的自然感受。
摄像头帧
有关画布捕获循环,请参见 Multimodal
— 将 <video> 绘制到画布,toDataURL('image/jpeg', 0.6),
去除前缀,并以适合提供商的节奏(~700 毫秒 Gemini,~2.5 秒 OpenAI)发送
video_frame 消息。
清单
- 密钥 + 工具位于服务器上,绝不在浏览器中。
- 在音频 之前 发送
ready,带上input_rate/output_rate;以这些速率构建AudioContext。 - 以
input_rate捕获麦克风为 PCM16 单声道。 - 以
output_rate使用调度游标无缝播放代理音频。 - 在
user_speaking(打断)时刷新播放。 - 在
send_text之后调用create_response()(不适用于 VAD 音频)。 - 将
video_frame发送错误视为非致命错误。 - 当匹配
ServerEvent时_ => {}臂(它是#[non_exhaustive])。
下一步:示例 →