بناء تطبيقات الويب

هذا هو الدليل العملي لوضع وكيل في الوقت الفعلي في المتصفح: نمط الجسر من جانب الخادم، بروتوكول WebSocket، وكود Web Audio الذي يلتقط الميكروفون ويشغل الوكيل بسلاسة. كل مثال ويب في هذا القسم مبني بهذه الطريقة بالضبط؛ خادم customer_service هو التنفيذ المرجعي.

لماذا جسر من جانب الخادم

لا يمكن للمتصفح أن يحتفظ بـ WebSocket الخاص بالمزود في الوقت الفعلي مباشرة:

  • سيتم شحن OPENAI_API_KEY / GEMINI_API_KEY الخاص بك إلى كل عميل،
  • ستعمل الأدوات في المتصفح، بعيدًا عن بياناتك وبيانات الاعتماد الخاصة بك،
  • ستكون مقيدًا بتنسيق سلكي لمزود واحد في كود العميل.

لذا فإن المتصفح هو جهاز صوت/فيديو رفيع وخادم Rust الخاص بك يمتلك الجلسة:

  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ألحق بفقاعة Agent
user_transcript_deltadeltaتسمية توضيحية مباشرة لكلام المستخدم
user_transcripttextالنسخة النهائية لكلام المستخدم
user_speaking / user_stoppedحالة VAD (شغل مؤشر الميكروفون؛ امسح التشغيل عند 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 أعلاه، مع إرجاع 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

يريد المزود PCM16 أحادي خام بالمعدل المتفاوض عليه input_rate. التقط باستخدام 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;
}

استدعِ flushPlayback() على رسالة user_speaking حتى يتوقف الوكيل عن الكلام فورًا عند مقاطعة المستخدم — الشعور الطبيعي للمحادثة الحقيقية.

إطارات الكاميرا

انظر Multimodal لحلقة التقاط اللوحة — ارسم <video> إلى لوحة، toDataURL('image/jpeg', 0.6)، قم بإزالة البادئة، وأرسل رسالة video_frame بإيقاع مناسب للمزود (~700 مللي ثانية Gemini، ~2.5 ثانية OpenAI).

قائمة تحقق

  • المفتاح + الأدوات موجودة على الخادم، وليس المتصفح أبدًا.
  • أرسل ready مع input_rate/output_rate قبل الصوت؛ أنشئ AudioContexts بهذه المعدلات.
  • التقط الميكروفون كـ PCM16 أحادي عند input_rate.
  • قم بتشغيل صوت الوكيل بدون فجوات باستخدام مؤشر جدولة عند output_rate.
  • امسح التشغيل عند user_speaking (مقاطعة).
  • استدعِ create_response() بعد send_text (ليس لصوت VAD).
  • تعامل مع أخطاء إرسال video_frame على أنها غير قاتلة.
  • _ => {} ذراع عند مطابقة ServerEvent (إنه #[non_exhaustive]).

التالي: Examples →