构建 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_audioaudio (base64 PCM16)一段麦克风音频
video_framemime, data (base64)一个摄像头帧
texttext一条输入的聊天消息
hangup结束会话

服务器 → 浏览器:

type字段渲染为
readyprovider, input_rate, output_rate协商的音频速率 — 创建您的 AudioContexts
audioaudio (base64 PCM16)排队等待无缝播放
agent_transcriptdelta追加到代理的气泡
user_transcript_deltadelta用户的实时语音字幕
user_transcripttext最终用户转录
user_speaking / user_stoppedVAD 状态 (驱动麦克风指示器;在 user_speaking 上刷新播放以进行插话)
toolname, args一个“正在运行工具…”的芯片
response_done回合结束
errormessage显示错误

这是一个薄的、应用定义的映射,基于 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])。

下一步:示例 →