الكتابات/tutorial/2026/07
Tutorial9 يوليو 2026·30 دقيقة

بروتوكول AG-UI مع Next.js: بث وكلاء الذكاء الاصطناعي إلى واجهتك بأحداث مُنمّطة (2026)

تعلّم كيفية بناء وكيل متوافق مع بروتوكول AG-UI بلغة TypeScript وبثّه داخل واجهة Next.js. يغطي هذا الدليل العملي تصنيف الأحداث وفئة AbstractAgent ومعالِجات SSE وواجهة HttpAgent والحالة المشتركة عبر تصحيحات JSON Patch وتوليد الواجهات من استدعاءات الأدوات.

ثلاثة بروتوكولات تُعرّف اليوم منظومة توافقية الوكلاء، ومعظم الفرق تبنّت اثنين منها فقط.

MCP يوحّد كيفية تحدّث الوكيل مع الأدوات والبيانات. A2A يوحّد كيفية تحدّث الوكيل مع وكيل آخر. أما الطبقة التي يتحدث فيها الوكيل مع الإنسان — بثّ الرموز إلى نافذة المحادثة، وعرض استدعاء أداة لم يكتمل بعد، ومزامنة خطة يستطيع المستخدم تعديلها أثناء التنفيذ — فقد ظلت مخصّصة وعشوائية. كل إطار عمل اخترع شكل حمولة SSE خاصًا به. LangGraph يبثّ بطريقة، وCrewAI بطريقة أخرى، وحلقتك المنزلية بطريقة ثالثة. غيّر الواجهة الخلفية، وستعيد كتابة الواجهة الأمامية بالكامل.

AG-UI (بروتوكول تفاعل الوكيل مع المستخدم) يسدّ هذه الفجوة. إنه بروتوكول مفتوح قائم على الأحداث، يضم نحو عشرين نوع حدث قياسي تتدفق عبر ناقل قابل للاستبدال — أحداث الخادم المرسَلة (SSE) أو WebSockets أو Webhooks. إذا كان وكيلك يبثّ أحداث AG-UI، فإن أي واجهة أمامية متوافقة مع AG-UI تستطيع عرضه. انزع LangGraph، وضع مكانه حلقة Anthropic خام، ولن تلاحظ الواجهة أي فرق.

في هذا الدليل ستبني المنظومة كاملة من الصفر: وكيل AG-UI مخصص مدعوم بنموذج Claude، مكشوف عبر معالِج مسار في Next.js يعمل بـ SSE، ويستهلكه عميل React يعرض النص المتدفق واستدعاءات الأدوات الحيّة وكائن حالة مشتركة يعدّله الوكيل والمستخدم معًا.

المتطلبات المسبقة

قبل أن تبدأ، تأكد من توفّر ما يلي:

  • Node.js 20 أو أحدث ومدير حزم (نستخدم pnpm هنا، لكن npm يعمل بشكل مطابق)
  • إلمام بـ Next.js 15 أو 16 — موجّه التطبيق App Router، ومعالِجات المسارات، ومكوّنات العميل
  • ارتياح مع TypeScript، بما في ذلك الأنواع العامة والاتحادات المميّزة
  • مفتاح Anthropic API من console.anthropic.com، للخطوة التي نربط فيها ذكاءً حقيقيًا
  • فهم أساسي لـ كائنات RxJS Observables — سنشرح الأجزاء التي تحتاجها، لكن معرفة أن observer.next() تدفع قيمة إلى التدفق ستفيدك

لست بحاجة إلى CopilotKit أو LangGraph أو أي إطار وكلاء. صُمّم AG-UI ليكون مستقلًا عن أطر العمل، والبناء مقابل البروتوكول الخام هو أسرع طريقة لفهم ما تفعله تلك الأطر نيابة عنك.

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

تطبيق Next.js واحد بنصفين:

الواجهة الخلفية — فئة ترث من AbstractAgent وتغلّف واجهة Anthropic Messages API. تترجم تدفق Claude الأصلي إلى أحداث AG-UI: دورة حياة التشغيل، ودلتا النص، واستدعاءات الأدوات، وتصحيحات الحالة. ثم يقوم معالِج مسار عند /api/agent بتسلسل هذه الأحداث كإطارات SSE.

الواجهة الأمامية — مكوّن عميل يُنشئ HttpAgent، ويوجّهه إلى /api/agent، ويشترك في تدفق الأحداث. يظهر النص رمزًا برمز. تُعرض استدعاءات الأدوات كبطاقات حالة حيّة قبل وصول نتائجها. ويبقى كائن «خطة بحث» مشتركة متزامنًا بين الوكيل والمستخدم عبر تصحيحات JSON Patch.

في النهاية لن تفهم فقط كيفية استخدام AG-UI، بل ستفهم لماذا صُمّم تصنيف أحداثه بهذا الشكل.

الخطوة 1: تجهيز المشروع

أنشئ تطبيق Next.js جديدًا. يوفّر AG-UI قالبًا رسميًا (npx create-ag-ui-app my-agent-app)، لكننا سنبني من الصفر حتى لا يبقى شيء مخفيًا.

pnpm create next-app@latest ag-ui-demo --typescript --tailwind --app --no-src-dir
cd ag-ui-demo

ثبّت حزم AG-UI بالإضافة إلى حزمة Anthropic:

pnpm add @ag-ui/client @ag-ui/core rxjs @anthropic-ai/sdk

ثلاث حزم تنجز العمل:

  • @ag-ui/core تحتوي على البروتوكول نفسه — تعداد EventType، ومخططات Zod التي تتحقق من صحة كل حدث، والأنواع المشتركة في TypeScript. لا تفرض أي سلوك تشغيلي.
  • @ag-ui/client توفّر AbstractAgent (الفئة الأساسية التي ترث منها على الخادم) وHttpAgent (العميل الذي يستهلك نقطة نهاية SSE). كما تتضمن واجهة AgentSubscriber.
  • rxjs توفّر بدائية Observable التي تُرجعها الدالة AbstractAgent.run().

أضف مفتاحك إلى ملف .env.local:

ANTHROPIC_API_KEY=sk-ant-your-key-here

لا تُدرج ملف .env.local في المستودع أبدًا. ملف .gitignore الافتراضي في Next.js يستبعده، لكن تحقّق قبل أول رفع — مفاتيح Anthropic المسرّبة تُجمع من المستودعات العامة خلال دقائق.

الخطوة 2: فهم تصنيف الأحداث

كل شيء في AG-UI هو حدث. قبل كتابة أي كود، استوعب الفئات التالية — هذا هو البروتوكول بأكمله، وهو يتّسع لعقلك دفعة واحدة.

دورة حياة التشغيل. يحيط الحدثان RUN_STARTED وRUN_FINISHED باستدعاء واحد للوكيل. ويحلّ RUN_ERROR محل RUN_FINISHED عند حدوث خلل. أما STEP_STARTED وSTEP_FINISHED فيحدّدان المراحل الداخلية ضمن التشغيل، مما يتيح للواجهة عرض «جارٍ البحث…» ثم «جارٍ التلخيص…».

الرسائل النصية. يعلن TEXT_MESSAGE_START عن رسالة مساعد جديدة بمعرّف messageId ودور role. ويحمل TEXT_MESSAGE_CONTENT حقل delta — أي رمزًا أو مقطعًا. ويغلقها TEXT_MESSAGE_END. وهناك أيضًا TEXT_MESSAGE_CHUNK المختصر الذي يدمج الثلاثة عندما لا تحتاج إلى تحكم دقيق.

استدعاءات الأدوات. يتضمن TOOL_CALL_START اسم الأداة toolCallName. ويبثّ TOOL_CALL_ARGS وسائط JSON على شكل شظايا بينما ينتجها النموذج. ويغلق TOOL_CALL_END الاستدعاء، بينما يسلّم TOOL_CALL_RESULT ناتج التنفيذ. تفصيلة بثّ الوسائط هذه مهمة: تستطيع عرض استعلام بحث نصف مكتوب في الواجهة قبل أن ينهي النموذج تحديد ما سيبحث عنه أصلًا.

الحالة. يرسل STATE_SNAPSHOT كائن حالة كاملًا. ويرسل STATE_DELTA مصفوفة من عمليات JSON Patch (وفق المعيار RFC 6902) لتعديله تدريجيًا. هذه هي الآلية الكامنة خلف الحالة المشتركة — الوكيل يملك مستندًا، والواجهة تعكسه، وكلاهما يستطيع تصحيحه.

التفكير والاستدلال. أحداث مثل THINKING_START وTHINKING_TEXT_MESSAGE_CONTENT وREASONING_MESSAGE_CHUNK تكشف آثار الاستدلال الموسّع، فتستطيع الواجهة عرض لوحة «مسار التفكير» قابلة للطي منفصلة عن الإجابة.

مخارج الطوارئ. يحمل CUSTOM حمولات مسمّاة اعتباطية. ويمرّر RAW الحدث الأصلي من المزوّد كما هو دون تعديل. ويعيد MESSAGES_SNAPSHOT ضبط سجل المحادثة كاملًا — وهو مفيد بعد إعادة الاتصال.

كل حدث هو كائن JSON بسيط يحمل حقل type. هذا كل شيء. لا استدعاءات إجراءات بعيدة، ولا مصافحة ثنائية الاتجاه على مستوى البروتوكول — مجرد تدفق مُنمّط ينساب من الوكيل إلى الواجهة، بينما تعود مدخلات المستخدم كأجسام طلبات HTTP عادية.

الخطوة 3: بناء وكيل بسيط

ابدأ بأبسط وكيل ممكن حتى يتضح شكل AbstractAgent تمامًا. أنشئ الملف lib/agent/simple-agent.ts:

import {
  AbstractAgent,
  BaseEvent,
  EventType,
  RunAgentInput,
} from "@ag-ui/client"
import { Observable } from "rxjs"
 
export class SimpleAgent extends AbstractAgent {
  run(input: RunAgentInput): Observable<BaseEvent> {
    const { threadId, runId } = input
 
    return new Observable<BaseEvent>((observer) => {
      // افتح التشغيل
      observer.next({
        type: EventType.RUN_STARTED,
        threadId,
        runId,
      } as BaseEvent)
 
      const messageId = crypto.randomUUID()
 
      observer.next({
        type: EventType.TEXT_MESSAGE_START,
        messageId,
        role: "assistant",
      } as BaseEvent)
 
      observer.next({
        type: EventType.TEXT_MESSAGE_CONTENT,
        messageId,
        delta: "Hello from AG-UI.",
      } as BaseEvent)
 
      observer.next({
        type: EventType.TEXT_MESSAGE_END,
        messageId,
      } as BaseEvent)
 
      // أغلق التشغيل
      observer.next({
        type: EventType.RUN_FINISHED,
        threadId,
        runId,
      } as BaseEvent)
 
      observer.complete()
    })
  }
}

ثلاث قواعد تحكم الدالة run():

  1. أحِط دائمًا بـ RUN_STARTED وحدث ختامي. التشغيل الذي لا يبثّ RUN_FINISHED أو RUN_ERROR يترك العميل يدور إلى الأبد.
  2. يجب أن يبقى messageId ثابتًا عبر الثلاثي START وCONTENT وEND. يستخدمه العميل لتوجيه الدلتا إلى المخزن الصحيح للرسالة.
  3. استدعِ observer.complete() عند انتهاء التدفق. نسيانها يُبقي اتصال SSE مفتوحًا.

يحمل المعامل input كل ما يحتاجه الوكيل: threadId وrunId ومصفوفة messages (سجل المحادثة الكامل) وكائن state وأدوات tools أتاحتها الواجهة الأمامية، بالإضافة إلى context — أزواج مفتاح/قيمة اعتباطية تبثّها الواجهة، مثل منطقة العرض الحالية أو الصف الذي اختاره المستخدم للتو.

الخطوة 4: ربط نموذج Claude

استبدل الآن الرد الجاهز بنموذج حقيقي. أنشئ الملف lib/agent/claude-agent.ts:

import {
  AbstractAgent,
  BaseEvent,
  EventType,
  RunAgentInput,
} from "@ag-ui/client"
import Anthropic from "@anthropic-ai/sdk"
import { Observable } from "rxjs"
 
const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
})
 
export class ClaudeAgent extends AbstractAgent {
  run(input: RunAgentInput): Observable<BaseEvent> {
    const { threadId, runId, messages } = input
 
    return new Observable<BaseEvent>((observer) => {
      const emit = (event: Record<string, unknown>) =>
        observer.next(event as BaseEvent)
 
      const execute = async () => {
        emit({ type: EventType.RUN_STARTED, threadId, runId })
 
        const messageId = crypto.randomUUID()
        let opened = false
 
        const stream = anthropic.messages.stream({
          model: "claude-sonnet-5",
          max_tokens: 2048,
          messages: messages
            .filter((m) => m.role === "user" || m.role === "assistant")
            .map((m) => ({
              role: m.role as "user" | "assistant",
              content: m.content ?? "",
            })),
        })
 
        for await (const chunk of stream) {
          if (
            chunk.type === "content_block_delta" &&
            chunk.delta.type === "text_delta"
          ) {
            if (!opened) {
              emit({
                type: EventType.TEXT_MESSAGE_START,
                messageId,
                role: "assistant",
              })
              opened = true
            }
            emit({
              type: EventType.TEXT_MESSAGE_CONTENT,
              messageId,
              delta: chunk.delta.text,
            })
          }
        }
 
        if (opened) {
          emit({ type: EventType.TEXT_MESSAGE_END, messageId })
        }
 
        emit({ type: EventType.RUN_FINISHED, threadId, runId })
        observer.complete()
      }
 
      execute().catch((error: unknown) => {
        emit({
          type: EventType.RUN_ERROR,
          message: error instanceof Error ? error.message : "Unknown error",
        })
        observer.complete()
      })
 
      // يعمل هذا التنظيف إذا ألغى العميل اشتراكه أثناء البث
      return () => {
        // ألغِ الطلب الصاعد هنا في بيئة الإنتاج
      }
    })
  }
}

لاحظ الراية opened. نحن نبثّ TEXT_MESSAGE_START فقط عند وصول أول دلتا نصية فعليًا — وإلا فإن تشغيلًا يستدعي أداة مباشرة سيفتح فقاعة رسالة فارغة يراها المستخدم تومض ثم تختفي.

كتلة catch تؤدي عملًا حقيقيًا أيضًا. فالرفض غير المعالَج داخل Observable يقتل التدفق بصمت دون أي حدث ختامي. تحويله إلى RUN_ERROR يعني أن الواجهة تستطيع عرض حالة فشل بدلًا من التعليق.

دالة التفكيك التي يُرجعها بانيء الـ Observable تُستدعى عند انقطاع العميل. في بيئة الإنتاج، استدعِ stream.abort() هناك. بدونها، فإن مستخدمًا يغلق التبويب أثناء التوليد سيستمر في استهلاك الرموز حتى ينتهي النموذج.

الخطوة 5: كشف الوكيل عبر SSE

الناقل الافتراضي في AG-UI هو أحداث الخادم المرسَلة. يصبح كل حدث إطار SSE واحدًا: البادئة الحرفية data: ، ثم الحدث مُسلسلًا بصيغة JSON، ثم سطران جديدان.

أنشئ الملف app/api/agent/route.ts:

import { ClaudeAgent } from "@/lib/agent/claude-agent"
import type { RunAgentInput } from "@ag-ui/client"
import type { NextRequest } from "next/server"
 
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
 
export async function POST(req: NextRequest) {
  const input = (await req.json()) as RunAgentInput
  const agent = new ClaudeAgent()
  const encoder = new TextEncoder()
 
  const stream = new ReadableStream({
    start(controller) {
      const subscription = agent.run(input).subscribe({
        next(event) {
          const frame = `data: ${JSON.stringify(event)}\n\n`
          controller.enqueue(encoder.encode(frame))
        },
        error(err) {
          const payload = JSON.stringify({
            type: "RUN_ERROR",
            message: String(err),
          })
          controller.enqueue(encoder.encode(`data: ${payload}\n\n`))
          controller.close()
        },
        complete() {
          controller.close()
        },
      })
 
      // ألغِ الاشتراك عند انقطاع اتصال HTTP
      req.signal.addEventListener("abort", () => {
        subscription.unsubscribe()
        controller.close()
      })
    },
  })
 
  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
    },
  })
}

ترويستان توفّران عليك ساعات من التنقيح. تمنع no-transform الوسطاء من ضغط التدفق (وهو ما يخزّنه مؤقتًا). وتخبر X-Accel-Buffering: no خادم nginx بألّا يحتجز الإطارات. بدونهما يعمل التدفق بلا عيب على جهازك المحلي، ثم يصل في الإنتاج ككتلة واحدة ضخمة.

كذلك فإن export const runtime = "nodejs" مهم. بيئة Edge تعمل، لكن Node تمنحك دلالات إلغاء التدفق الكاملة في حزمة Anthropic.

الخطوة 6: استهلاك التدفق عبر HttpAgent

الآن النصف الخاص بالعميل. تتولى HttpAgent إدارة اتصال SSE وتحليل الإطارات والتحقق من صحة الأحداث وتجميع الرسائل. أنت توفّر AgentSubscriber — كائنًا من دوال رد نداء اختيارية، واحدة لكل نوع حدث.

أنشئ الملف lib/agent/client.ts:

import { HttpAgent } from "@ag-ui/client"
 
export const agent = new HttpAgent({
  url: "/api/agent",
})

ثم شغّله:

await agent.runAgent(
  { /* حقول RunAgentInput إضافية */ },
  {
    onTextMessageStartEvent() {
      console.log("assistant message opened")
    },
    onTextMessageContentEvent({ event }) {
      process.stdout.write(event.delta)
    },
    onTextMessageEndEvent() {
      console.log("\nassistant message closed")
    },
    onToolCallStartEvent({ event }) {
      console.log("tool call:", event.toolCallName)
    },
    onToolCallArgsEvent({ event }) {
      console.log("args delta:", event.delta)
    },
    onToolCallResultEvent({ event }) {
      console.log("tool result:", event.content)
    },
    onRunErrorEvent({ event }) {
      console.error("run failed:", event.message)
    },
  },
)

نمط المشترك هو أنظف ما في AG-UI. لن تكتب أبدًا جملة switch على أنواع الأحداث، ولن تحلّل إطارات SSE يدويًا، ولن تقلق بشأن وصول TEXT_MESSAGE_CONTENT قبل START الخاص به. تتحقق HttpAgent من الترتيب مقابل مخطط البروتوكول وتُطلق خطأً عند المخالفة.

الخطوة 7: العرض في React

اربط المشترك بحالة المكوّن. أنشئ الملف app/components/chat.tsx:

"use client"
 
import { HttpAgent } from "@ag-ui/client"
import { useCallback, useRef, useState } from "react"
 
type Message = { id: string; role: "user" | "assistant"; text: string }
 
export function Chat() {
  const [messages, setMessages] = useState<Message[]>([])
  const [running, setRunning] = useState(false)
  const [input, setInput] = useState("")
  const agentRef = useRef(new HttpAgent({ url: "/api/agent" }))
 
  const send = useCallback(async () => {
    if (!input.trim() || running) return
 
    const userMessage: Message = {
      id: crypto.randomUUID(),
      role: "user",
      text: input,
    }
    setMessages((prev) => [...prev, userMessage])
    setInput("")
    setRunning(true)
 
    const agent = agentRef.current
    agent.messages = [
      ...agent.messages,
      { id: userMessage.id, role: "user", content: userMessage.text },
    ]
 
    await agent.runAgent(
      {},
      {
        onTextMessageStartEvent({ event }) {
          setMessages((prev) => [
            ...prev,
            { id: event.messageId, role: "assistant", text: "" },
          ])
        },
        onTextMessageContentEvent({ event }) {
          setMessages((prev) =>
            prev.map((m) =>
              m.id === event.messageId
                ? { ...m, text: m.text + event.delta }
                : m,
            ),
          )
        },
        onRunErrorEvent({ event }) {
          setMessages((prev) => [
            ...prev,
            {
              id: crypto.randomUUID(),
              role: "assistant",
              text: `Error: ${event.message}`,
            },
          ])
        },
        onRunFinishedEvent() {
          setRunning(false)
        },
      },
    )
  }, [input, running])
 
  return (
    <div className="mx-auto flex h-screen max-w-2xl flex-col p-4">
      <div className="flex-1 space-y-3 overflow-y-auto">
        {messages.map((m) => (
          <div
            key={m.id}
            className={
              m.role === "user"
                ? "ml-auto max-w-[80%] rounded-lg bg-blue-600 p-3 text-white"
                : "mr-auto max-w-[80%] rounded-lg bg-gray-100 p-3"
            }
          >
            {m.text}
          </div>
        ))}
      </div>
 
      <div className="mt-4 flex gap-2">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && send()}
          placeholder="اسأل الوكيل…"
          className="flex-1 rounded-lg border px-4 py-2"
          disabled={running}
        />
        <button
          onClick={send}
          disabled={running}
          className="rounded-lg bg-blue-600 px-6 py-2 text-white disabled:opacity-50"
        >
          {running ? "…" : "إرسال"}
        </button>
      </div>
    </div>
  )
}

ضَع المكوّن في app/page.tsx، وشغّل pnpm dev، وستحصل على محادثة متدفقة فوق بروتوكول AG-UI في نحو 120 سطرًا.

التفصيلة المهمة هي agent.messages. تحتفظ HttpAgent بسجل المحادثة داخليًا وترسله مع كل تشغيل، لذا تدفع دور المستخدم إليه قبل استدعاء runAgent(). أما أدوار المساعد فتُضاف تلقائيًا مع انسياب التدفق.

الخطوة 8: الحالة المشتركة عبر تصحيحات JSON Patch

هنا يتوقف AG-UI عن كونه «صيغة بثّ أخرى» ويبدأ في استحقاق اسمه. المحادثة أحادية الاتجاه. الحالة المشتركة ليست كذلك.

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

يبثّ الوكيل STATE_SNAPSHOT لترسيخ الشكل، ثم أحداث STATE_DELTA تحمل عمليات JSON Patch:

// داخل دالة run() الخاصة بوكيلك
 
emit({
  type: EventType.STATE_SNAPSHOT,
  snapshot: {
    plan: [
      { id: "1", title: "Gather sources", status: "pending" },
      { id: "2", title: "Extract claims", status: "pending" },
      { id: "3", title: "Draft summary", status: "pending" },
    ],
  },
})
 
// لاحقًا، عند اكتمال الخطوة الأولى — صحّح بدل إعادة إرسال كل شيء
emit({
  type: EventType.STATE_DELTA,
  delta: [
    { op: "replace", path: "/plan/0/status", value: "complete" },
    {
      op: "add",
      path: "/plan/0/sourceCount",
      value: 12,
    },
  ],
})

كل كائن في delta هو عملية JSON Patch وفق المعيار RFC 6902. والعمليات المدعومة هي add وremove وreplace وmove وcopy وtest. إرسال تصحيح بدلًا من لقطة كاملة يعني أن خطة من خمسين خطوة يتغير فيها حقل حالة واحد تكلّف نحو 80 بايت على الشبكة بدلًا من عدة كيلوبايتات.

على جانب العميل، اشترك في الحدثين معًا:

type PlanStep = { id: string; title: string; status: string }
type AgentState = { plan: PlanStep[] }
 
const [state, setState] = useState<AgentState>({ plan: [] })
 
await agent.runAgent(
  {},
  {
    onStateSnapshotEvent({ event }) {
      setState(event.snapshot as AgentState)
    },
    onStateDeltaEvent({ event }) {
      setState((prev) => applyPatch(structuredClone(prev), event.delta))
    },
  },
)

استخدم مكتبة مثل fast-json-patch من أجل applyPatch بدلًا من كتابتها بنفسك — فعمليتا move وtest تحملان دلالات ترتيب دقيقة يسهل الخطأ فيها.

التصحيحات منطقية فقط إذا لم تنحرف حالة العميل عن حالة الوكيل أبدًا. إذا فشل تطبيق تصحيح — أعادت عملية test قيمة خاطئة، أو لم يعد المسار موجودًا — فلا تتجاهل ذلك بصمت. اطلب STATE_SNAPSHOT جديدة وأعد المزامنة. الانحراف الصامت ينتج واجهات تبدو صحيحة بينما هي تكذب بمهارة.

ولأن الحالة كائن بسيط منعكس على الجانبين، فإن الاتجاه العكسي هو ببساطة جسم الطلب التالي. عندما يسحب المستخدم الخطوة الثالثة فوق الثانية، عدّل الحالة محليًا، ثم مرّر الكائن المحدّث كحقل state في استدعاء runAgent() التالي. يقرأ الوكيل input.state فيرى تعديل المستخدم. هذه الدورة الكاملة هي ما يعنيه «التزامن ثنائي الاتجاه للحالة» عمليًا — دون أي حاجة إلى WebSocket.

الخطوة 9: توليد الواجهات من استدعاءات الأدوات

القطعة الأخيرة هي عرض استدعاءات الأدوات كواجهة بدلًا من نص. ولأن TOOL_CALL_ARGS يبثّ شظايا الوسائط، تستطيع إظهار التقدم قبل أن تُستدعى الأداة أصلًا.

امنح Claude أداة، ومرّر أحداث تدفقه، ودع الواجهة تقرر كيف تعرض كل toolCallName:

// الخادم: ترجمة كتل tool_use من Claude إلى أحداث AG-UI
for await (const chunk of stream) {
  if (
    chunk.type === "content_block_start" &&
    chunk.content_block.type === "tool_use"
  ) {
    emit({
      type: EventType.TOOL_CALL_START,
      toolCallId: chunk.content_block.id,
      toolCallName: chunk.content_block.name,
    })
  }
 
  if (
    chunk.type === "content_block_delta" &&
    chunk.delta.type === "input_json_delta"
  ) {
    emit({
      type: EventType.TOOL_CALL_ARGS,
      toolCallId: currentToolCallId,
      delta: chunk.delta.partial_json,
    })
  }
 
  if (chunk.type === "content_block_stop" && currentToolCallId) {
    emit({ type: EventType.TOOL_CALL_END, toolCallId: currentToolCallId })
  }
}

ثم اربط أسماء الأدوات بمكوّنات على جانب العميل:

const TOOL_COMPONENTS: Record<string, React.FC<{ args: string }>> = {
  search_web: SearchCard,
  run_query: QueryCard,
  generate_chart: ChartCard,
}
 
// داخل المشترك الخاص بك
onToolCallStartEvent({ event }) {
  setToolCalls((prev) => [
    ...prev,
    { id: event.toolCallId, name: event.toolCallName, args: "" },
  ])
},
onToolCallArgsEvent({ event }) {
  setToolCalls((prev) =>
    prev.map((t) =>
      t.id === event.toolCallId ? { ...t, args: t.args + event.delta } : t,
    ),
  )
},
onToolCallResultEvent({ event }) {
  setToolCalls((prev) =>
    prev.map((t) =>
      t.id === event.toolCallId ? { ...t, result: event.content } : t,
    ),
  )
},

يستقبل SearchCard سلسلة JSON جزئية. حلّلها بتسامح — فمساعِد يحتمل JSON المبتور يتيح لك عرض «جارٍ البحث عن AG-UI proto…» بينما لا يزال النموذج يبثّ الرموز. تلك هي التفصيلة الصغيرة التي تجعل واجهة الوكيل تبدو حيّة لا متأخرة.

اختبار التنفيذ

تحقّق من كل طبقة على حدة بدلًا من تنقيح المسار كاملًا دفعة واحدة.

اختبر تدفق الأحداث باستخدام curl. يجب أن ترى إطارات SSE خامًا، إطارًا في كل سطر:

curl -N -X POST http://localhost:3000/api/agent \
  -H "Content-Type: application/json" \
  -d '{
    "threadId": "t1",
    "runId": "r1",
    "messages": [{ "id": "m1", "role": "user", "content": "Say hello" }],
    "state": {},
    "tools": [],
    "context": []
  }'

الراية -N تعطّل التخزين المؤقت في curl. إذا وصلت الإطارات دفعة واحدة عند انتهاء التشغيل، فإن ترويسات التخزين المؤقت لديك خاطئة — راجع الخطوة 5.

تحقّق من الأحداث مقابل المخطط. تصدّر @ag-ui/core مخططات Zod لكل نوع حدث. تحقّق منها في اختبار وحدة:

import { EventSchemas } from "@ag-ui/core"
import { describe, expect, it } from "vitest"
 
describe("ClaudeAgent", () => {
  it("emits protocol-valid events in order", async () => {
    const events: BaseEvent[] = []
    await new Promise<void>((resolve) => {
      new ClaudeAgent()
        .run(testInput)
        .subscribe({ next: (e) => events.push(e), complete: resolve })
    })
 
    for (const event of events) {
      expect(() => EventSchemas.parse(event)).not.toThrow()
    }
 
    expect(events.at(0)?.type).toBe(EventType.RUN_STARTED)
    expect(events.at(-1)?.type).toBe(EventType.RUN_FINISHED)
  })
})

افحص باستخدام AG-UI Dojo. تتضمن التطبيقات المرجعية على dojo.ag-ui.com فاحص أحداث خام. وجّهه إلى نقطة النهاية المحلية لديك لترى بالضبط أي أحداث يبثّها وكيلك، وبأي ترتيب.

استكشاف الأخطاء وإصلاحها

الأحداث تصل دفعة واحدة بدل التدفق. السبب دائمًا تقريبًا وسيط يخزّن مؤقتًا. تأكد من وجود Cache-Control: no-cache, no-transform وX-Accel-Buffering: no في الاستجابة. وعلى Vercel، تأكد أن المسار يصدّر dynamic = "force-dynamic" كي لا يُحسَّن بشكل ثابت.

«وصل TEXT_MESSAGE_CONTENT قبل TEXT_MESSAGE_START». إما أن حارس opened معكوس، أو أنك تبثّ دلتا لمعرّف messageId أغلقته مسبقًا. كل messageId يدعم دورة حياة واحدة فقط من START وCONTENT وEND.

التشغيل لا ينتهي أبدًا. نسيت observer.complete()، أو أفلت استثناء من الدالة غير المتزامنة داخل الـ Observable. غلّف الجسم بـ try/catch وابثّ دائمًا حدثًا ختاميًا.

تصحيحات الحالة تُطبَّق على الفهرس الخاطئ. مسارات JSON Patch موضعية، لذا فإن /plan/0/status يشير إلى أي عنصر يقع أولًا حاليًا. إذا أعاد المستخدم ترتيب القائمة محليًا بين تشغيلين، فإن تصحيح الوكيل التالي سيستهدف عنصرًا غير الذي قصده. فضّل كائنات مفهرسة بمعرّفات ثابتة على المصفوفات في أي شيء يستطيع المستخدم إعادة ترتيبه، أو أعد إرسال لقطة كاملة بعد إعادة الترتيب.

وسائط الأداة تصل كـ JSON غير صالح. هذا متوقع — فدلتا TOOL_CALL_ARGS مجرد شظايا. حلّلها فقط بعد TOOL_CALL_END، أو استخدم محلّل JSON جزئيًا لمسار العرض التفاؤلي.

أخطاء أنواع عند observer.next(). اتحاد الأحداث في @ag-ui/core صارم. تستخدم الأمثلة المنشورة as BaseEvent اختصارًا؛ أما في الإنتاج فابنِ دوال مصنع صغيرة ومُنمّطة مثل runStarted(threadId, runId) حتى يلتقط المصرّف الحقول الناقصة.

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

لديك الآن وكيل AG-UI يعمل، لكن للبروتوكول مساحة أوسع تستحق الاستكشاف:

  • بدّل الواجهة الخلفية. استبدل ClaudeAgent بوكيل من LangGraph أو Mastra أو Pydantic AI باستخدام محوّلات AG-UI الرسمية لها. يجب أن تستمر واجهة الخطوة 7 في العمل دون تعديل — هذا هو الوعد الكامل، ويستحق أن تثبته لنفسك.
  • أضف طبقة وسيطة. طبقة النقل في AG-UI قابلة للاستبدال. أدخِل مشتركًا يسجّل كل حدث في منظومة المراقبة لديك، أو آخر يحجب البيانات الشخصية من دلتا TEXT_MESSAGE_CONTENT قبل مغادرتها الخادم.
  • أضف الإنسان في الحلقة. ابثّ حدث CUSTOM عندما يحتاج الوكيل إلى موافقة، وأوقف التشغيل مؤقتًا، ثم استأنفه بقرار المستخدم في input.context.
  • ادمجه مع MCP. اجعل وكيل AG-UI لديك يستدعي خوادم MCP لأدواته. راجع دليلنا حول بناء خادم MCP بلغة TypeScript — تتدفق نتائج الأدوات مباشرة إلى أحداث TOOL_CALL_RESULT.
  • قارنه بـ A2A. يغطي AG-UI العلاقة بين الوكيل والمستخدم، بينما يغطي A2A العلاقة بين وكيل وآخر. وأنظمة الإنتاج تتحدث اللغتين بشكل متزايد.

الخاتمة

AG-UI بروتوكول صغير يحل مشكلة غير برّاقة: طبقة التفاعل بين الوكيل والشخص الذي يراقبه وهو يعمل. رهانه أن نحو عشرين حدثًا مُنمّطًا — دورة حياة التشغيل، ودلتا النص، واستدعاءات الأدوات، وتصحيحات الحالة، وآثار الاستدلال — تكفي للتعبير عن كل واجهة وكيل تستحق البناء، وأن توحيدها يحرّرك لتغيير أطر الوكلاء دون إعادة كتابة واجهتك الأمامية.

القطع بسيطة بمجرد تجميعها. تمنحك AbstractAgent مكانًا لترجمة التدفق الأصلي لأي نموذج إلى أحداث بروتوكول. ويشحنها معالِج مسار SSE. وتحوّلها HttpAgent مع AgentSubscriber مجددًا إلى حالة React. وتُبقي تصحيحات JSON Patch مستندًا مشتركًا متزامنًا بتكلفة زهيدة في الاتجاهين.

ما ينبغي أن تخرج به هو الانضباط الذي يفرضه البروتوكول: أحِط كل تشغيل بحدثي بداية ونهاية، وأبقِ معرّفات الرسائل ثابتة، وابثّ حدثًا ختاميًا في كل مسار بما فيها الفشل، ولا تدع حالة العميل تنحرف عن حالة الوكيل دون إعادة مزامنة. هذه هي القواعد التي تفصل واجهة وكيل تبدو متينة عن أخرى تتعلّق بشكل غامض في ثلاثة بالمئة من التشغيلات — وAG-UI يجعلها صريحة بدلًا من ترك كل فريق يعيد اكتشافها بنفسه.