دليل OpenAI Realtime API 2026: بناء وكيل صوتي ذكي مع Next.js و WebRTC

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

الصوت هو الواجهة التي تجعل وكلاء الذكاء الاصطناعي يبدون أحياء أخيراً. روبوتات الدردشة النصية لها سقف، فهي تجبر المستخدم على الانتظار والقراءة وإعادة القراءة. الوكيل الصوتي الذي يردّ في حدود 300 مللي ثانية، ويسمح بمقاطعته في منتصف الجملة، ويستدعي أدوات حقيقية على الخادم، يبدو وكأنه فئة منتج مختلفة تماماً. واجهة OpenAI Realtime جعلت هذه التجربة في متناول اليد دون الحاجة إلى تجميع خط أنابيب من تحويل الكلام إلى نص ثم نموذج لغوي ثم محرك تحويل النص إلى كلام. نموذج واحد، اتصال واحد، مزدوج الاتجاه.

في هذا الدليل ستبني وكيلاً صوتياً يعمل في المتصفح، ويتحدث مع OpenAI Realtime عبر WebRTC، ويستدعي أدوات على الخادم من خلال استدعاء الدوال، ويبقى آمناً للنشر في الإنتاج. سنستخدم Next.js 15 (App Router) و TypeScript وعميل WebRTC خفيف. في النهاية سيكون لديك وكيل عملي قادر على استقبال مكالمة عميل، والبحث عن طلب في قاعدة بياناتك، وتلاوة الإجابة بصوت طبيعي.

ما الذي ستبنيه

وكيل دعم صوتي لمتجر إلكتروني افتراضي. يضغط المستخدم زراً، يطرح سؤاله بصوته، فيرد الوكيل صوتياً. خلف الكواليس يمكنه:

  • بثّ صوت الميكروفون إلى OpenAI في الوقت الفعلي
  • إرجاع الصوت عبر السماعات بزمن استجابة أقل من ثانية
  • اكتشاف متى يتحدث المستخدم ومقاطعة نفسه إن تمت مقاطعته
  • استدعاء أداة lookup_order على الخادم تتصل بقاعدة بيانات
  • حفظ نص المحادثة لمراجعتها أو ضبط النموذج لاحقاً

المتطلبات الأساسية

قبل البدء تأكد من توفر:

  • Node.js 20 أو أحدث
  • مفتاح OpenAI API مع صلاحية الوصول إلى Realtime
  • إلمام أساسي بـ React و Next.js App Router و TypeScript
  • متصفح حديث (Chrome أو Edge أو Safari) مع إذن الميكروفون
  • محرر شيفرة، ويفضّل VS Code

من المستحسن أيضاً أن تكون مرتاحاً مع مفاهيم WebRTC على مستوى عالٍ. لن نبني نظام الإشارات يدوياً، فـ OpenAI تعرض نقطة دخول HTTP واحدة تتولى تبادل SDP عنك.

لماذا Realtime API بدلاً من خط الأنابيب التقليدي

الحزمة الصوتية التقليدية تبدو كالتالي: ميكروفون ثم Whisper ثم GPT ثم خدمة TTS مثل ElevenLabs. كل قفزة تضيف زمن استجابة وتجعل المقاطعة صعبة. واجهة Realtime تستبدل كل ذلك بنموذج متعدد الوسائط واحد يستهلك إطارات صوتية ويصدر إطارات صوتية. كما تتولى اكتشاف النشاط الصوتي على الخادم، مما يجعل المقاطعة وتبادل الأدوار واكتشاف الصمت تعمل تلقائياً.

سببان آخران مهمّان للإنتاج:

  • فاتورة واحدة وحد واحد للمعدل. لا حاجة للتنسيق بين حصص ثلاثة موردين.
  • استدعاء الدوال أصلي. نفس النموذج الذي يسمع المستخدم يمكنه استدعاء أدواتك دون رحلة ذهاب وإياب إضافية.

الخطوة 1: إعداد المشروع

أنشئ تطبيق Next.js جديد مع TypeScript و Tailwind:

npx create-next-app@latest voice-agent --typescript --app --tailwind --eslint
cd voice-agent
npm install openai zod

أضف مفتاح API الخاص بك إلى ملف بيئة محلي:

# .env.local
OPENAI_API_KEY=sk-proj-...

يجب ألا تصل قيمة OPENAI_API_KEY إلى المتصفح أبداً. سنستخدمها فقط على الخادم لإصدار رموز مؤقتة قصيرة العمر يستطيع المتصفح استخدامها لفتح اتصال WebRTC.

الخطوة 2: إصدار رموز مؤقتة على الخادم

أكثر خطأ شائع مع Realtime هو إرسال مفتاح API الرئيسي إلى العميل. لا تفعل ذلك. اعرض بدلاً من ذلك Route Handler في Next.js يعيد رمزاً مؤقتاً صالحاً لـ 60 ثانية مرتبطاً بجلسة محددة.

أنشئ app/api/realtime/session/route.ts:

import { NextResponse } from "next/server";
 
export async function POST() {
  const response = await fetch(
    "https://api.openai.com/v1/realtime/sessions",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: "gpt-realtime",
        voice: "marin",
        modalities: ["audio", "text"],
        instructions:
          "أنت وكيل صوتي مفيد ومختصر لمتجر إلكتروني. أجب دائماً بنفس لغة المستخدم. تأكد من أرقام الطلبات قبل استدعاء lookup_order.",
      }),
    },
  );
 
  if (!response.ok) {
    return NextResponse.json(
      { error: "فشل إنشاء الجلسة" },
      { status: 500 },
    );
  }
 
  const data = await response.json();
  return NextResponse.json(data);
}

تحتوي الاستجابة على client_secret.value وهو الرمز المؤقت. صلاحيته نحو دقيقة واحدة وهو محدود بجلسة واحدة. حتى لو سرقه المستخدم من تبويب الشبكة فلن يستطيع إكمال غير المكالمة الجارية.

الخطوة 3: ربط المتصفح عبر WebRTC

WebRTC هو الناقل الصحيح هنا لأنه ينقل الصوت مع تخزين مؤقت مدمج للتذبذب، واسترجاع الحزم المفقودة، وترميز Opus. ويمنح المتصفح أيضاً تحكماً منخفض المستوى في الالتقاط والتشغيل.

أنشئ lib/realtime-client.ts:

export type RealtimeEvent =
  | { type: "session.created"; session: unknown }
  | { type: "input_audio_buffer.speech_started" }
  | { type: "input_audio_buffer.speech_stopped" }
  | { type: "response.audio_transcript.delta"; delta: string }
  | { type: "response.function_call_arguments.done"; name: string; call_id: string; arguments: string }
  | { type: "error"; error: { message: string } };
 
export interface RealtimeClient {
  pc: RTCPeerConnection;
  dc: RTCDataChannel;
  audio: HTMLAudioElement;
  stop: () => void;
}
 
export async function startRealtime(
  onEvent: (event: RealtimeEvent) => void,
): Promise<RealtimeClient> {
  const tokenRes = await fetch("/api/realtime/session", { method: "POST" });
  const { client_secret } = await tokenRes.json();
 
  const pc = new RTCPeerConnection();
 
  const audio = new Audio();
  audio.autoplay = true;
  pc.ontrack = (event) => {
    audio.srcObject = event.streams[0];
  };
 
  const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
  pc.addTrack(mic.getAudioTracks()[0]);
 
  const dc = pc.createDataChannel("oai-events");
  dc.onmessage = (event) => {
    const parsed = JSON.parse(event.data) as RealtimeEvent;
    onEvent(parsed);
  };
 
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
 
  const sdpRes = await fetch(
    "https://api.openai.com/v1/realtime?model=gpt-realtime",
    {
      method: "POST",
      body: offer.sdp,
      headers: {
        Authorization: `Bearer ${client_secret.value}`,
        "Content-Type": "application/sdp",
      },
    },
  );
 
  const answer = { type: "answer" as const, sdp: await sdpRes.text() };
  await pc.setRemoteDescription(answer);
 
  return {
    pc,
    dc,
    audio,
    stop: () => {
      mic.getTracks().forEach((t) => t.stop());
      dc.close();
      pc.close();
    },
  };
}

بعض التفاصيل التي يسهل نسيانها:

  • يجب أن يحدث استدعاء getUserMedia داخل معالج لحدث مستخدم، وإلا فسيرفضه المتصفح.
  • اسم data channel هو oai-events وهو مفروض من قبل الواجهة.
  • لا نضبط pc.onicecandidate أبداً. تقبل نقطة Realtime عرض SDP واحداً وتعيد الجواب في تبادل واحد، لذا لا حاجة لـ trickle ICE.

الخطوة 4: بناء الواجهة

أنشئ مكون عميل بسيط يبدأ الوكيل ويوقفه. احفظه في app/page.tsx:

"use client";
 
import { useRef, useState } from "react";
import { startRealtime, type RealtimeClient } from "@/lib/realtime-client";
 
export default function Home() {
  const [status, setStatus] = useState<"idle" | "connecting" | "live">("idle");
  const [transcript, setTranscript] = useState("");
  const clientRef = useRef<RealtimeClient | null>(null);
 
  async function start() {
    setStatus("connecting");
    const client = await startRealtime((event) => {
      if (event.type === "response.audio_transcript.delta") {
        setTranscript((prev) => prev + event.delta);
      }
      if (event.type === "session.created") {
        setStatus("live");
      }
    });
    clientRef.current = client;
  }
 
  function stop() {
    clientRef.current?.stop();
    clientRef.current = null;
    setStatus("idle");
  }
 
  return (
    <main className="mx-auto max-w-2xl p-8 space-y-6">
      <h1 className="text-3xl font-bold">الوكيل الصوتي</h1>
      <button
        onClick={status === "idle" ? start : stop}
        className="rounded-full bg-black text-white px-6 py-3"
      >
        {status === "idle" ? "ابدأ المكالمة" : "أنهِ المكالمة"}
      </button>
      <p className="text-sm text-zinc-500">الحالة: {status}</p>
      <pre className="whitespace-pre-wrap rounded-lg bg-zinc-100 p-4 text-sm">
        {transcript}
      </pre>
    </main>
  );
}

شغّل npm run dev، انقر ابدأ المكالمة، اسمح بالوصول إلى الميكروفون، ثم قل مرحباً. ينبغي أن يردّ الوكيل خلال نحو 500 مللي ثانية.

الخطوة 5: إضافة الأدوات عبر استدعاء الدوال

الوكيل الصوتي الذي يكتفي بالدردشة هو مجرد لعبة. الجزء المثير يبدأ حين يستطيع التصرف. تستخدم Realtime نفس آلية استدعاء الدوال المعتمدة على مخطّطات JSON المستخدمة في Chat Completions. عرّف الأدوات عند إنشاء الجلسة.

حدّث جسم الـ Route Handler في /api/realtime/session ليتضمن مصفوفة tools:

body: JSON.stringify({
  model: "gpt-realtime",
  voice: "marin",
  modalities: ["audio", "text"],
  instructions:
    "أنت وكيل صوتي مفيد ومختصر لمتجر إلكتروني. تأكد من أرقام الطلبات قبل استدعاء lookup_order.",
  tools: [
    {
      type: "function",
      name: "lookup_order",
      description: "البحث عن حالة طلب عميل عبر رقم الطلب.",
      parameters: {
        type: "object",
        properties: {
          order_number: {
            type: "string",
            description: "رقم الطلب، مثال ORD-12345.",
          },
        },
        required: ["order_number"],
      },
    },
  ],
  tool_choice: "auto",
}),

عندما يقرر النموذج استدعاء الأداة، يطلق data channel حدث response.function_call_arguments.done. المتصفح ليس المكان المناسب لتشغيل منطق الأعمال، لذا وجّه الاستدعاء إلى الخادم، نفّذه، ثم أعد النتيجة عبر نفس data channel.

في app/page.tsx وسّع معالج الأحداث:

const client = await startRealtime(async (event) => {
  if (event.type === "response.audio_transcript.delta") {
    setTranscript((prev) => prev + event.delta);
  }
  if (event.type === "response.function_call_arguments.done") {
    const args = JSON.parse(event.arguments);
    const res = await fetch("/api/orders/lookup", {
      method: "POST",
      body: JSON.stringify(args),
    });
    const result = await res.json();
 
    clientRef.current?.dc.send(
      JSON.stringify({
        type: "conversation.item.create",
        item: {
          type: "function_call_output",
          call_id: event.call_id,
          output: JSON.stringify(result),
        },
      }),
    );
    clientRef.current?.dc.send(JSON.stringify({ type: "response.create" }));
  }
});

ثم أضف مسار الخادم في app/api/orders/lookup/route.ts:

import { NextResponse } from "next/server";
import { z } from "zod";
 
const Schema = z.object({ order_number: z.string() });
 
export async function POST(request: Request) {
  const body = Schema.parse(await request.json());
 
  const order = await db.orders.findUnique({
    where: { id: body.order_number },
  });
 
  if (!order) {
    return NextResponse.json({ found: false });
  }
 
  return NextResponse.json({
    found: true,
    status: order.status,
    expected_delivery: order.expectedDelivery,
  });
}

سيستمع الوكيل الآن إلى رقم الطلب، ويكرّره للتأكيد، ويستدعي خادمك، ويقرأ الإجابة بصوت طبيعي. كل ذلك في دور محادثة واحد.

الخطوة 6: اكتشاف النشاط الصوتي والمقاطعة

افتراضياً تستخدم Realtime VAD على الخادم للكشف عن حدود الأدوار. هذا يعني أن النموذج يعرف متى ينتهي المستخدم من الكلام ويبدأ الردّ فوراً. كما يعرف متى يستأنف المستخدم الكلام، وهي الطريقة التي تعمل بها المقاطعة، حيث يتلقى العميل حدث response.cancelled ويتوقف عن التشغيل.

يمكنك ضبط VAD بإرسال حدث session.update بعد إنشاء الجلسة:

clientRef.current?.dc.send(
  JSON.stringify({
    type: "session.update",
    session: {
      turn_detection: {
        type: "server_vad",
        threshold: 0.5,
        prefix_padding_ms: 300,
        silence_duration_ms: 600,
      },
    },
  }),
);

قيمة silence_duration_ms تساوي 600 خيار افتراضي جيد. القيم الأقل تجعل الوكيل يقفز بسرعة، والقيم الأكبر تجعله بطيئاً.

الخطوة 7: حفظ نصوص المحادثات

للتحليلات أو سجلات التدقيق أو الضبط الدقيق ستحتاج إلى حفظ النص الكامل على الخادم. تطلق Realtime أحداث conversation.item.created لطرفي المحادثة. مرّرها إلى نقطة دخول صغيرة للسجلات:

if (event.type === "conversation.item.created") {
  fetch("/api/logs/turn", {
    method: "POST",
    body: JSON.stringify({
      sessionId: client.sessionId,
      role: event.item.role,
      content: event.item.content,
      ts: Date.now(),
    }),
  });
}

خزّن هذه البيانات في Postgres أو أي سجل قابل للإلحاق فقط. لا تخزّن الصوت الخام دون سبب واضح ودون موافقة المستخدم، فهذا حديث امتثال منفصل.

الخطوة 8: اعتبارات الإنتاج

النسخة التجريبية التي تعمل على حاسوبك ليست وكيل إنتاج. قبل الإطلاق:

  • حدّ معدل نقطة دخول الجلسة. كل استدعاء لـ /api/realtime/session ينشئ جلسة OpenAI مدفوعة. غلّفها بـ Upstash أو Arcjet كي لا يعيد مستخدم واحد الضغط على ابدأ المكالمة ويستهلك ميزانيتك.
  • اضبط حداً أقصى صارماً للمدة. يمكن أن تستمر جلسات Realtime حتى 30 دقيقة. أضف مؤقتاً على العميل يستدعي stop() بعد حدّ منطقي، مثلاً خمس دقائق لمكالمة دعم.
  • أخفِ البيانات الشخصية في النصوص. مرّر النص المحفوظ بخطوة تنقيح قبل أن يصل إلى مستودع البيانات.
  • عرّب الصوت محلياً. مرّر لغة المستخدم في رسالة النظام كي يختار النموذج اللهجة المناسبة. تستفيد منطقة الشرق الأوسط وشمال أفريقيا تحديداً من رسالة نظام بالعربية الفصيحة بدلاً من تعليمات إنجليزية تُترجَم آنياً.
  • راقب عبر Langfuse أو OpenTelemetry. تتبّع زمن الاستجابة، ومعدل نجاح استدعاء الأدوات، ومتوسط تكلفة الجلسة لكل مستخدم.

استكشاف الأخطاء

لا يظهر طلب إذن الميكروفون. استدعيت getUserMedia خارج إيماءة مستخدم. غلّف الاستدعاء داخل معالج النقر على زر ابدأ المكالمة.

الصوت يصل متقطّعاً. تأكد من ضبط autoplay على عنصر <audio>. بعض المتصفحات تمنع التشغيل التلقائي حتى يتفاعل المستخدم مع الصفحة مرة واحدة. النقرة الأولى على ابدأ المكالمة تكفي.

الوكيل لا يستدعي الأداة أبداً. تأكد من ضبط tool_choice: "auto" ومن أن رسالة النظام لا تمنع استخدام الأدوات. افحص أيضاً حدث response.function_call_arguments.done في المنقّح، فأحياناً يخترع النموذج رقم طلب قبل طلبه، ما يعني أن عليك تشديد الرسالة.

اتصال WebRTC يفشل خلف بروكسي مؤسسي. أضف خادم TURN. لمعظم عمليات النشر العامة تكفي إعدادات STUN الافتراضية من الواجهة.

الخطوات التالية

أصبح لديك الآن العمود الفقري لمنتج صوتي حقيقي. من هنا يمكنك إضافة:

  • تسليم متعدد الوكلاء — وجّه مكالمة من وكيل فرز إلى أخصائي فوترة عبر أداة transfer_to_agent. راجع دليل وكلاء LangGraph الحاملين للحالة للنمط التنسيقي.
  • إجابات مدعومة بالاسترجاع — أعطِ الوكيل أداة search_docs تتصل بمخزن المتجهات. يغطي دليل RAG الوكلائي مع Next.js جانب الفهرسة.
  • التكامل الهاتفي — اربط الوكيل بمكالمات هاتفية واردة وصادرة عبر جسر SIP من Twilio Voice أو LiveKit.
  • إمكانية الرصد — وصّل كل استدعاء أداة وكل دور إلى Langfuse لقياس الجودة بين الإصدارات.

الخاتمة

تختزل واجهة Realtime حزمة الذكاء الاصطناعي الصوتي إلى شيء يمكن لفريق صغير صيانته فعلاً. ثلاثة أمور كانت الأكثر أهمية في هذا البناء: الرموز المؤقتة لإبقاء المفتاح الرئيسي على الخادم، و WebRTC لإبقاء زمن الاستجابة بشرياً، واستدعاء الدوال لجعل الوكيل مفيداً لا مجرد ثرثار. مع وجود هذه العناصر الأساسية يصبح ما تبقّى عملاً منتجياً، يتمحور حول تعريف الأدوات الصحيحة، وكتابة رسالة نظام محكمة، وتوصيل أدوات قياس على المحادثات التي ستذهب إلى الإنتاج.

الصوت لم يعد حيلة. الفرق التي تتعلم إطلاقه الآن هي التي ستحدد الجيل القادم من الذكاء الاصطناعي الموجّه للعملاء.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على نشر تطبيق Next.js على AWS باستخدام SST Ion: دليل شامل للحوسبة السحابية بدون خوادم.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة