كل منتج ذكاء اصطناعي يحتاج في النهاية إلى واجهة محادثة، وبناء واجهة بمستوى صقل ChatGPT أو Claude.ai أصعب بكثير مما يبدو. عرض Markdown أثناء البث دون وميض، تمرير تلقائي يتوقف عندما يصعد المستخدم للأعلى، تحرير الرسائل وإعادة توليدها، التنقل بين الفروع، عرض استدعاءات الأدوات، المرفقات — كل واحدة من هذه الميزات مشروع مصغّر بحد ذاتها.
assistant-ui تحل هذه المشكلة. فهي مكتبة React مفتوحة المصدر الأكثر شعبية لمحادثات الذكاء الاصطناعي، وتشغّل واجهات لدى الشركات الناشئة والمؤسسات الكبرى على حد سواء. بدلاً من تقديم أداة جاهزة مغلقة، تتبع فلسفة shadcn/ui: عناصر أولية قابلة للتركيب تعيش داخل مشروعك، منسقة بـ Tailwind CSS، وتملكها وتخصصها بالكامل.
في هذا الدرس، ستبني تطبيق محادثة ذكاء اصطناعي كاملاً وجاهزاً للإنتاج باستخدام Next.js 15 و assistant-ui و Vercel AI SDK، مدعوماً بنموذج Claude من Anthropic. في النهاية، ستحصل على محادثة بالبث المباشر، وعرض مخصص للأدوات، وواجهات توليدية، ومسار واضح نحو الإنتاج.
ما الذي ستبنيه
تطبيق محادثة ذكاء اصطناعي متكامل يتضمن:
- ردوداً بالبث المباشر في الوقت الفعلي مع عرض Markdown
- تحرير الرسائل وإعادة التوليد والتنقل بين الفروع (مثل تدفق التحرير في ChatGPT)
- أداة طقس مع بطاقة واجهة توليدية مخصصة تُعرض داخل المحادثة
- منطقة عرض بتمرير تلقائي تحترم موضع تمرير المستخدم
- اختصارات لوحة المفاتيح وحالات التحميل وإمكانية الوصول جاهزة مباشرة
المتطلبات المسبقة
قبل البدء، تأكد من توفر:
- Node.js 20+ مثبتاً
- إلمام بـ React و Next.js App Router
- معرفة أساسية بـ TypeScript
- مفتاح API من Anthropic (أو أي مزود متوافق مع AI SDK)
لماذا assistant-ui بدلاً من البناء من الصفر؟
تتولى assistant-ui أكثر من 50 تفصيلاً في تجربة المستخدم كنت ستنفذها يدوياً: التمرير التلقائي الذكي، وتحريك النص أثناء البث، والتحديثات المتفائلة، ومعالجة الإلغاء، وتفريع الرسائل، وإمكانية وصول ARIA كاملة.
مقارنة سريعة بين الأساليب الرئيسية في 2026:
| الأسلوب | ملكية الكود | الجهد | المرونة |
|---|---|---|---|
| البناء من الصفر | كاملة | مرتفع جداً | كاملة |
| أدوات المحادثة المدمجة | معدومة | منخفض | منخفضة |
| Vercel AI Elements | كاملة | متوسط | عالية |
| assistant-ui | كاملة | منخفض | عالية |
الميزة القاتلة في assistant-ui هي معمارية العناصر الأولية. كما فعلت Radix UI مع المكونات العامة، توفر عناصر أولية قابلة للتركيب بلا تنسيق (ThreadPrimitive و MessagePrimitive و ComposerPrimitive) إضافة إلى طبقة انطلاق منسقة تُولَّد داخل مشروعك وتعدّلها بحرية.
الخطوة 1: إعداد المشروع
أنشئ مشروع Next.js 15 جديداً:
npx create-next-app@latest assistant-chat --typescript --tailwind --eslint --app
cd assistant-chatالآن هيّئ assistant-ui. تولّد أداة سطر الأوامر مكونات المحادثة مباشرة داخل مشروعك، على طريقة shadcn:
npx assistant-ui@latest initهذا الأمر:
- يثبّت
@assistant-ui/reactو@assistant-ui/react-ai-sdkو@assistant-ui/react-markdown - ينشئ مكونات منسقة تحت
components/assistant-ui/(الخيط، وصندوق الإدخال، ونص Markdown، والمزيد) - يضيف إعدادات Tailwind ومتغيرات CSS المطلوبة
ثبّت AI SDK ومزود Anthropic للواجهة الخلفية:
npm install ai @ai-sdk/anthropicأضف مفتاح API إلى .env.local:
ANTHROPIC_API_KEY=sk-ant-...لا تُدرج .env.local في git أبداً. يجب أن يوجد مفتاح API على الخادم فقط — تضمن معمارية assistant-ui أن جميع استدعاءات النموذج تمر عبر مسار الواجهة الخلفية، وليس من المتصفح أبداً.
الخطوة 2: إنشاء مسار API للمحادثة
تعمل assistant-ui مع أي واجهة خلفية عبر نظام الـ runtime. الخيار الأكثر شيوعاً هو AI SDK runtime، الذي يتصل بمعالج مسار AI SDK قياسي.
أنشئ app/api/chat/route.ts:
import { anthropic } from "@ai-sdk/anthropic";
import { streamText, convertToModelMessages, type UIMessage } from "ai";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: anthropic("claude-sonnet-5"),
system:
"أنت مساعد مفيد. كن موجزاً واستخدم تنسيق Markdown.",
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}ثلاث نقاط جديرة بالملاحظة:
streamTextيبدأ استدعاء النموذج ويعود فوراً بنتيجة قابلة للبثconvertToModelMessagesيحوّل صيغة رسائل الواجهة (التي تتضمن استدعاءات الأدوات والمرفقات) إلى صيغة المزودtoUIMessageStreamResponseيُصدر تدفق SSE الذي يستهلكه runtime الخاص بـ assistant-ui مباشرة
الخطوة 3: ربط الـ Runtime Provider
الـ runtime هو عقل assistant-ui — يدير حالة الرسائل والبث والتفريع والتواصل مع API الخاص بك. غلّف تطبيقك بالـ provider.
أنشئ app/assistant.tsx:
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { Thread } from "@/components/assistant-ui/thread";
export function Assistant() {
const runtime = useChatRuntime({
api: "/api/chat",
});
return (
<AssistantRuntimeProvider runtime={runtime}>
<main className="h-dvh">
<Thread />
</main>
</AssistantRuntimeProvider>
);
}ثم اعرضه من صفحتك. استبدل app/page.tsx:
import { Assistant } from "./assistant";
export default function Home() {
return <Assistant />;
}شغّل خادم التطوير:
npm run devافتح http://localhost:3000 وستجد محادثة تعمل بالفعل: ردود بالبث المباشر، وعرض Markdown، وكتل كود بتلوين نحوي، وأزرار نسخ، وإعادة توليد الرسائل، وتمرير تلقائي ذكي. هذا هو الإعداد الأساسي بأكمله — ملفان وأمر واحد في سطر الأوامر.
الخطوة 4: فهم معمارية العناصر الأولية
افتح components/assistant-ui/thread.tsx. كل ما تراه على الشاشة مركّب من عناصر أولية تملكها الآن. نظرة مبسطة على البنية:
<ThreadPrimitive.Root>
<ThreadPrimitive.Viewport>
<ThreadPrimitive.Messages
components={{
UserMessage,
AssistantMessage,
EditComposer,
}}
/>
</ThreadPrimitive.Viewport>
<Composer />
</ThreadPrimitive.Root>كل عنصر أولي يتولى السلوك، لا المظهر:
ThreadPrimitive.Viewportينفذ التمرير التلقائي الذكي — يتابع البث لكنه يتوقف عن المتابعة لحظة صعود المستخدم للأعلى، ثم يعرض زر «الانتقال إلى الأسفل»ThreadPrimitive.Messagesيعرض قائمة الرسائل ويربط أدوار الرسائل بمكوناتكMessagePrimitive.Partsيعرض أجزاء الرسالة — النص والاستدلال واستدعاءات الأدوات — لكل منها مكوّنه الخاصComposerPrimitiveيدير الإدخال: الإرسال بـ Enter، وسطر جديد بـ Shift+Enter، وحالات التعطيل أثناء البث، والإلغاء بـ Escape
ولأن هذه ملفاتك، فالتخصيص مباشر. تريد تغيير صورة المساعد الرمزية، أو إعادة ترتيب أزرار الإجراءات، أو إضافة تذييل مخصص لكل رسالة؟ عدّل المكوّن — لا واجهة إعدادات تقاومك، ولا تجاوزات CSS على أسماء فئات غريبة.
تخصيص شاشة الترحيب
ابحث عن مكوّن ThreadWelcome في thread.tsx واجعله خاصاً بك:
const ThreadWelcome = () => {
return (
<div className="flex flex-col items-center justify-center py-16">
<h1 className="text-2xl font-semibold">مساعد نقطة</h1>
<p className="text-muted-foreground mt-2">
اسألني أي شيء عن مشاريعك.
</p>
<div className="mt-6 flex gap-2">
<ThreadPrimitive.Suggestion
prompt="بماذا يمكنك مساعدتي؟"
method="replace"
autoSend
className="rounded-full border px-4 py-2 text-sm hover:bg-muted"
>
بماذا يمكنك مساعدتي؟
</ThreadPrimitive.Suggestion>
</div>
</div>
);
};يعرض ThreadPrimitive.Suggestion شريحة اقتراح قابلة للنقر تملأ (أو ترسل) صندوق الإدخال — نفس النمط الذي يستخدمه ChatGPT لاقتراحاته الافتتاحية.
الخطوة 5: إضافة أداة مع واجهة توليدية مخصصة
هنا تتألق assistant-ui. تتيح الأدوات للنموذج استدعاء دوال في واجهتك الخلفية؛ أما واجهات الأدوات فتتيح لك عرض تلك الاستدعاءات كمكونات تفاعلية غنية داخل المحادثة بدلاً من JSON خام.
أولاً، عرّف الأداة على الخادم. حدّث app/api/chat/route.ts:
import { anthropic } from "@ai-sdk/anthropic";
import {
streamText,
convertToModelMessages,
tool,
type UIMessage,
} from "ai";
import { z } from "zod";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: anthropic("claude-sonnet-5"),
system: "أنت مساعد مفيد لديه صلاحية الوصول إلى بيانات الطقس.",
messages: convertToModelMessages(messages),
tools: {
getWeather: tool({
description: "الحصول على الطقس الحالي لمدينة ما",
inputSchema: z.object({
city: z.string().describe("اسم المدينة"),
}),
execute: async ({ city }) => {
// في الإنتاج، استدعِ هنا API طقس حقيقياً
const conditions = ["مشمس", "غائم", "ممطر", "عاصف"];
return {
city,
temperature: Math.round(18 + Math.random() * 15),
condition:
conditions[Math.floor(Math.random() * conditions.length)],
humidity: Math.round(40 + Math.random() * 40),
};
},
}),
},
});
return result.toUIMessageStreamResponse();
}الآن أنشئ الواجهة المخصصة لهذه الأداة. أنشئ components/assistant-ui/weather-tool.tsx:
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
type WeatherArgs = {
city: string;
};
type WeatherResult = {
city: string;
temperature: number;
condition: string;
humidity: number;
};
export const WeatherToolUI = makeAssistantToolUI<WeatherArgs, WeatherResult>({
toolName: "getWeather",
render: ({ args, status, result }) => {
if (status.type === "running") {
return (
<div className="my-2 animate-pulse rounded-xl border bg-muted/50 p-4">
جارٍ التحقق من الطقس في {args.city}...
</div>
);
}
if (status.type === "incomplete" || !result) {
return (
<div className="my-2 rounded-xl border border-destructive p-4">
تعذّر جلب بيانات الطقس.
</div>
);
}
return (
<div className="my-2 rounded-xl border bg-gradient-to-br from-sky-50 to-blue-100 p-4 dark:from-sky-950 dark:to-blue-900">
<div className="text-sm text-muted-foreground">{result.city}</div>
<div className="text-3xl font-bold">{result.temperature}°C</div>
<div className="mt-1 flex gap-4 text-sm">
<span>{result.condition}</span>
<span>الرطوبة: {result.humidity}%</span>
</div>
</div>
);
},
});تتلقى دالة render دورة الحياة الكاملة لاستدعاء الأداة:
status.type === "running"— أصدر النموذج الاستدعاء وexecuteقيد التنفيذ؛ تصلargsتدريجياً أثناء البث، فيمكنك عرضها فوراًstatus.type === "complete"— يحتويresultعلى ما أعادتهexecutestatus.type === "incomplete"— أُلغي الاستدعاء أو فشل
سجّل واجهة الأداة بعرضها في أي مكان داخل الـ provider. حدّث app/assistant.tsx:
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { Thread } from "@/components/assistant-ui/thread";
import { WeatherToolUI } from "@/components/assistant-ui/weather-tool";
export function Assistant() {
const runtime = useChatRuntime({
api: "/api/chat",
});
return (
<AssistantRuntimeProvider runtime={runtime}>
<WeatherToolUI />
<main className="h-dvh">
<Thread />
</main>
</AssistantRuntimeProvider>
);
}اسأل المحادثة «كيف الطقس في تونس؟» وشاهد النموذج يستدعي الأداة، وبطاقة التحميل تظهر، ثم بطاقة الطقس المتدرجة تُعرض داخل المحادثة — تجربة واجهة توليدية حقيقية في نحو 60 سطراً من الكود.
تُسجَّل واجهات الأدوات بوجود المكوّن، لا بالإعدادات. هذا يعني أنه يمكنك تقسيمها بالتحميل الكسول، أو تسجيلها شرطياً لكل صفحة، أو شحنها من وحدات ميزات منفصلة — يتحدث السجل تلقائياً عند التركيب والإزالة.
الخطوة 6: تفريع الرسائل وتحريرها
توفر assistant-ui تفريعاً على طريقة ChatGPT دون أي عمل إضافي. مرّر المؤشر فوق أي رسالة مستخدم وانقر أيقونة التحرير: إرسال التعديل ينشئ فرعاً جديداً، ويظهر منتقي فروع يعرض شيئاً مثل «2 / 2» مع أسهم للتنقل بين البدائل.
الأمر نفسه ينطبق على إعادة التوليد — النقر على «تحديث» في رسالة المساعد ينشئ فرعاً شقيقاً بدلاً من إتلاف الإجابة السابقة.
العناصر الأولية وراء ذلك، المربوطة مسبقاً في thread.tsx المولَّد:
<BranchPickerPrimitive.Root>
<BranchPickerPrimitive.Previous />
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
<BranchPickerPrimitive.Next />
</BranchPickerPrimitive.Root>تعيش حالة الفروع في شجرة الرسائل الداخلية للـ runtime، لذا تصمد أمام البث والإلغاءات واستدعاءات الأدوات دون أي إدارة حالة من جانبك.
الخطوة 7: قراءة حالة المحادثة والتحكم فيها
للميزات التي تتجاوز المكونات المدمجة — شريط جانبي مخصص، تحليلات، مشغّلات خارجية — توفر assistant-ui خطافات دقيقة وواجهة برمجية للـ runtime.
أرسل رسالة برمجياً (مثلاً من زر في مكان آخر بتطبيقك):
"use client";
import { useThreadRuntime } from "@assistant-ui/react";
export function QuickAction() {
const threadRuntime = useThreadRuntime();
return (
<button
onClick={() =>
threadRuntime.append({
role: "user",
content: [
{ type: "text", text: "لخّص محادثتنا حتى الآن." },
],
})
}
className="rounded-md border px-3 py-1.5 text-sm"
>
تلخيص
</button>
);
}اشترك في الحالة بشكل تفاعلي — مثلاً لتعطيل التنقل في التطبيق أثناء توليد النموذج:
import { useThread } from "@assistant-ui/react";
export function StreamingIndicator() {
const isRunning = useThread((t) => t.isRunning);
return isRunning ? (
<span className="text-xs text-muted-foreground">جارٍ التوليد…</span>
) : null;
}تُبقي المحدِّدات إعادة العرض محصورة: المكوّن أعلاه لا يُعاد عرضه إلا عند تغيّر isRunning، وليس مع كل رمز مبثوث.
الخطوة 8: التحصين للإنتاج
يختلف چات العرض التجريبي عن چات الإنتاج في حفنة من التفاصيل الحرجة.
تحديد المعدل
استدعاءات النموذج مكلفة. احمِ مسارك قبل الإطلاق:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, "1 m"),
});
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await ratelimit.limit(ip);
if (!success) {
return new Response("تم تجاوز حد الطلبات", { status: 429 });
}
// ... استدعاء streamText
}معالجة الأخطاء
يُظهر الـ runtime أخطاء المسار تلقائياً في واجهة الخيط. أعد رسائل ذات معنى بدلاً من ترك الاستثناءات تتحول إلى أخطاء 500 غامضة، وحافظ على توافق maxDuration مع حدود البث لدى مستضيفك (30 إلى 60 ثانية تغطي معظم الأدوار الحوارية؛ الأدوار الوكيلية الطويلة قد تحتاج أكثر).
الاستمرارية
يحتفظ useChatRuntime بالسجل في الذاكرة لكل جلسة. للحصول على سجل دائم متعدد الأجهزة، لديك مساران للإنتاج:
- Assistant Cloud — طبقة الاستمرارية المُدارة من assistant-ui: سجل الخيوط، وإدارة قائمة الخيوط، وتدفقات الموافقة البشرية ببضعة أسطر من الإعداد
- الاستضافة الذاتية — احفظ مصفوفات
UIMessageفي استدعاءonFinishالخاص بـtoUIMessageStreamResponse، مفهرسة بمعرّف الخيط، ثم رطّب الـ runtime بـinitialMessages
أياً كان اختيارك، احفظ صيغة رسائل الواجهة (وليس صيغة النموذج) — فهي التمثيل الكامل الذي يحتوي على استدعاءات الأدوات والمرفقات والبيانات الوصفية.
تحقق دائماً من معرّف الخيط وصلاحياته على الخادم في كل طلب. من الثغرات الشائعة في تطبيقات المحادثة الوثوق بمعرّف خيط يقدمه العميل، مما يسمح لأي مستخدم بتحميل سجل محادثات مستخدم آخر.
اختبار التنفيذ
تحقق من التدفق الكامل:
- البث — أرسل «اكتب هايكو عن تونس» وتأكد من تدفق الرموز بسلاسة مع عرض Markdown
- التمرير التلقائي — أرسل طلباً طويلاً، واصعد للأعلى أثناء البث، وتأكد من توقف منطقة العرض عن المتابعة؛ يجب أن يظهر زر الانتقال إلى الأسفل
- واجهة الأداة — اسأل «كيف الطقس في صفاقس؟» وتأكد من استبدال بطاقة التحميل ببطاقة الطقس
- التفريع — عدّل رسالتك الأولى، وأرسلها، وتأكد من أن منتقي الفروع يعرض فرعين يمكنك التنقل بينهما
- الإلغاء — اضغط Escape (أو انقر إيقاف) في منتصف التوليد وتأكد من توقف البث بشكل نظيف مع الاحتفاظ بالرسالة الجزئية
استكشاف الأخطاء وإصلاحها
الرسائل تُرسل لكن لا شيء يعود بالبث. تحقق من أن مسارك يعيد result.toUIMessageStreamResponse() — إعادة result.toTextStreamResponse() تنتج تدفق نص خام لا يحلله الـ runtime إلى أجزاء رسائل.
واجهة الأداة لا تظهر أبداً. يجب أن يطابق toolName في makeAssistantToolUI تماماً المفتاح في كائن tools داخل streamText، ويجب أن يكون المكوّن مركّباً داخل AssistantRuntimeProvider.
أنماط Tailwind تبدو معطلة. تأكد من أن أداة سطر الأوامر أضافت مسارات assistant-ui ومتغيرات CSS إلى إعداد Tailwind لديك؛ مع Tailwind v4، تحقق من توجيهات @source في CSS العام.
أخطاء TypeScript على UIMessage. تأكد من أن ai و @assistant-ui/react-ai-sdk كليهما على أحدث إصداراتهما الرئيسية — صيغة رسائل AI SDK v5 مطلوبة من قِبل الـ runtimes الحالية في assistant-ui.
الخطوات التالية
- أضف المرفقات باستخدام محوّلات المرفقات في assistant-ui (الصور وملفات PDF تتدفق إلى النماذج متعددة الوسائط)
- استكشف موافقة الأدوات بإشراف بشري، بعرض أزرار الموافقة/الرفض داخل واجهة الأداة قبل تشغيل
execute - اربط خوادم MCP عبر عميل MCP في AI SDK لمنح محادثتك أدوات خارجية دون كتابة كل واحدة يدوياً
- اقرأ دروسنا ذات الصلة: Vercel AI Elements، والمساعدات داخل التطبيق مع CopilotKit، وبناء خوادم MCP بلغة TypeScript
الخاتمة
لقد بنيت واجهة محادثة ذكاء اصطناعي بمستوى الإنتاج مع البث المباشر وواجهات الأدوات التوليدية والتفريع والتحكم البرمجي — بجزء يسير من الكود الذي كان سيتطلبه البناء من الصفر. معمارية العناصر الأولية في assistant-ui تعني أنك لن تصطدم أبداً بجدار التخصيص الذي تفرضه الأدوات المدمجة: كل مكوّن يعيش في مستودعك، منسقاً بثيم Tailwind الخاص بك، ومصاغاً وفق منتجك.
النمط الذي يجب تذكره: الـ runtime للحالة، والعناصر الأولية للسلوك، وكودك للمظهر. أتقن هذه الطبقات الثلاث وستصبح أي تجربة محادثة يمكنك تخيلها — تدفقات الموافقة، وعروض الوكلاء المتعددين، ولوحات المعلومات المدمجة — عمل أمسية من التركيب بدلاً من ربع سنة من البنية التحتية.