كل تطبيق ذكاء اصطناعي شُحن في 2025 انتهى به الأمر يُعيد اختراع نفس واجهة الدردشة: حاوية تمرير تثبّت نفسها أسفل الشاشة، فقاعات رسائل تتبدّل حسب الدور، حقل نصّ يكبر تلقائياً، زرّ إرسال يتحوّل إلى زرّ إيقاف أثناء البثّ، لوحة تفكير قابلة للطيّ، شرائح مرفقات. لا شيء من هذا صعب، لكنّه مجتمعاً يستهلك سباقاً كاملاً والنتيجة تبقى تبدو غير متقنة.
Vercel AI Elements هو الجواب على ذلك. هو مكتبة مكوّنات — مبنيّة على shadcn/ui ومضبوطة لـ AI SDK — تُقدّم كلّ هذه البُنى كمكوّنات React قابلة للتثبيت تملكها داخل مستودعك. تُشغّل أمر CLI واحد، يُنسَخ الكود المصدري إلى components/ai-elements، وتربطه بـ useChat. بنهاية هذا الدرس سيكون لديك تطبيق دردشة Next.js 15 ببثّ مباشر، وفواصل تفكير، ومرفقات ملفّات، ومُحدّد نموذج، وزرّ بحث ويب — دون أن تكتب قطعة واجهة واحدة من الصفر.
AI Elements ليس صندوقاً أسود. على عكس معظم مكتبات الدردشة، لا يُشحَن AI Elements عبر npm install. يستخدم نمط shadcn: ينسخ CLI ملفات مصدر حقيقية إلى مشروعك. تستطيع تعديل أيّ شيء، وتنسيق أيّ شيء، وحذف ما لا تحتاجه. تتولّى Vercel السجلّ، وأنت تملك الكود.
ما ستتعلّمه
بنهاية هذا الدرس ستكون قادراً على:
- تثبيت مكوّنات AI Elements في مشروع Next.js 15 App Router عبر CLI السجلّ
- بناء مسار
/api/chatباستخدامstreamTextوtoUIMessageStreamResponseمن AI SDK 5 - ربط
ConversationوMessageوMessageResponseلعرض مخرجات الذكاء الاصطناعي المتدفّقة - استخدام
PromptInputمعPromptInputTextareaوPromptInputSubmitوPromptInputBodyلحقل إدخال مصقول - إضافة مرفقات ملفّات عبر
PromptInputActionAddAttachmentsوبدائياتAttachments - عرض تفكير النموذج باستخدام
ReasoningوReasoningTriggerوReasoningContent - عرض استدعاءات الأدوات ومصادر الويب باستخدام
ToolوSourceوSources
المتطلّبات الأساسية
قبل البدء تأكّد من توفّر:
- Node.js 20 أو أحدث (بناء AI SDK 5 الحديث ESM يرفض الإصدارات الأقدم)
- إلمام بـ Next.js 15 App Router (مكوّنات الخادم مقابل مكوّنات العميل)
- مفتاح OpenAI أو Anthropic API — سنستخدم OpenAI لكن الاستبدال سطر واحد
- shadcn/ui مهيّأ مسبقاً (أو تترك CLI الخاصّ بـ AI Elements يهيّئه)
- ارتياح مع خطافات React و TypeScript
ما ستبنيه
تطبيق دردشة بصفحة واحدة باسم Noqta Chat بسلسلة محادثة حقيقية، وبثّ نصّ مباشر، وتفكير قابل للتوسعة، ومرفقات صور وملفّات PDF، ومُنتقي نموذج بين GPT-4o و Claude Sonnet 4.6، وزرّ تبديل "بحث الويب" يُغيّر السلوك على الخادم. ستُؤلَّف الواجهة بأكملها من بدائيات AI Elements التي تعيش في مجلّد components/ai-elements/ لديك.
الخطوة 1: إعداد المشروع
أنشئ مشروع Next.js 15 جديداً بـ TypeScript و Tailwind:
npx create-next-app@latest noqta-chat \
--typescript --tailwind --app --eslint --src-dir
cd noqta-chatهيّئ shadcn/ui (يحتاجها AI Elements كطبقة أساسية):
npx shadcn@latest initاختر Neutral لوناً أساسياً و CSS variables عند المطالبة. هذا يُهيّئ globals.css و lib/utils.ts و components.json.
الآن ثبّت AI Elements. أسرع طريق هو CLI المخصّص الذي يُضيف كلّ مكوّن دفعة واحدة:
npx ai-elements@latestينبغي أن تظهر ملفّات جديدة في components/ai-elements/: conversation.tsx و message.tsx و prompt-input.tsx و reasoning.tsx و tool.tsx و source.tsx و attachments.tsx و code-block.tsx وبضعة ملفّات أخرى. هذه ملفّات مصدر حقيقية — افتح أيّاً منها وسترى مكوّنات بنمط shadcn معياري مبنيّة على بدائيات Radix.
تفضّل تثبيت مكوّن واحد في كلّ مرّة؟ استخدم npx ai-elements@latest add conversation لإضافة بدائيات المحادثة فقط، أو استخدم سجلّ shadcn الأساسي مباشرة: npx shadcn@latest add https://elements.ai-sdk.dev/api/registry/all.json. كلاهما يُنتج نفس النتيجة.
الخطوة 2: تثبيت AI SDK 5
صُمّم AI Elements ليتّصل بخطّاف useChat في AI SDK 5. ثبّت البيئة التشغيلية وروابط React ومُزوّداً واحداً على الأقلّ:
npm install ai @ai-sdk/react @ai-sdk/openai @ai-sdk/anthropic zodأضف مفاتيحك إلى .env.local:
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...أعد تشغيل npm run dev ليلتقط Next.js متغيّرات البيئة الجديدة.
الخطوة 3: مسار API الدردشة
يبثّ AI SDK 5 رسائل واجهة مستخدم — صيغة منظّمة كلّ رسالة فيها قائمة من الأجزاء المُكتَبة (نصّ، تفكير، أداة، مصدر، ملفّ). تقرأ مكوّنات AI Elements هذه الأجزاء مباشرة، فالخادم يحتاج فقط إلى تمرير بثّ النموذج كبثّ رسائل واجهة.
أنشئ src/app/api/chat/route.ts:
import { openai } from "@ai-sdk/openai";
import { anthropic } from "@ai-sdk/anthropic";
import {
convertToModelMessages,
streamText,
type UIMessage,
} from "ai";
export const maxDuration = 30;
type ChatBody = {
messages: UIMessage[];
model?: string;
webSearch?: boolean;
};
export async function POST(req: Request) {
const { messages, model = "gpt-4o", webSearch = false }: ChatBody =
await req.json();
const provider = model.startsWith("claude")
? anthropic(model)
: openai(model);
const result = streamText({
model: provider,
system: webSearch
? "You can search the web. Cite sources you used."
: "You are a helpful assistant. Be concise and accurate.",
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse({
sendReasoning: true,
sendSources: true,
});
}أمران مهمّان هنا:
- يحوّل
convertToModelMessagesصيغة رسالة الواجهة (مصفوفة الأجزاء) إلى صيغة إكمال الدردشة التي يتوقّعها كلّ مُزوّد. - يُنتج
toUIMessageStreamResponseالبثّ المؤطّر الذي يستهلكهuseChatو AI Elements. تمريرsendReasoning: trueهو ما يُظهر تفكير Claude الموسّع؛ وsendSources: trueيجعل نتائج بحث الويب تتدفّق كأجزاءsource.
الخطوة 4: هيكل المحادثة الأساسي
استبدل src/app/page.tsx بمكوّن عميل يُعرّض هيكل المحادثة. سنبدأ بأقلّ ما يمكن ونُضيف الميزات في الخطوات اللاحقة.
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
Message,
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
import {
PromptInput,
type PromptInputMessage,
PromptInputTextarea,
PromptInputSubmit,
} from "@/components/ai-elements/prompt-input";
import { MessageSquare } from "lucide-react";
export default function ChatPage() {
const [input, setInput] = useState("");
const { messages, sendMessage, status } = useChat();
const handleSubmit = (message: PromptInputMessage) => {
if (!message.text.trim()) return;
sendMessage({ text: message.text });
setInput("");
};
return (
<main className="mx-auto flex h-dvh max-w-3xl flex-col p-6">
<Conversation className="flex-1">
<ConversationContent>
{messages.length === 0 ? (
<ConversationEmptyState
icon={<MessageSquare className="size-12" />}
title="Start a conversation"
description="Ask Noqta Chat anything"
/>
) : (
messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent>
{message.parts.map((part, i) =>
part.type === "text" ? (
<MessageResponse key={`${message.id}-${i}`}>
{part.text}
</MessageResponse>
) : null
)}
</MessageContent>
</Message>
))
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<PromptInput onSubmit={handleSubmit} className="mt-4">
<PromptInputTextarea
value={input}
placeholder="Send a message..."
onChange={(e) => setInput(e.currentTarget.value)}
/>
<PromptInputSubmit
status={status === "streaming" ? "streaming" : "ready"}
disabled={!input.trim()}
/>
</PromptInput>
</main>
);
}شغّل npm run dev، وافتح http://localhost:3000، ينبغي أن تكون لديك دردشة عاملة بالفعل. يتولّى Conversation سلوك التمرير التلقائي، ويُظهر ConversationScrollButton كبسولة "اقفز إلى الأحدث" عند التمرير لأعلى، ويتولّى MessageResponse عرض markdown المتدفّق. الهيكل بأكمله أقلّ من 60 سطر JSX.
الخطوة 5: التكرار على أجزاء الرسالة بالطريقة الصحيحة
النمط في الخطوة 4 يعرض فقط أجزاء text. الردود الحقيقية تتضمّن أيضاً أجزاء reasoning و tool-* و source-url و file. أعد تنظيم عرض الرسالة في مكوّن مخصّص ليكبر بنظافة في كلّ خطوة.
أنشئ src/components/chat-message-parts.tsx:
"use client";
import type { UIMessage } from "ai";
import {
MessageResponse,
} from "@/components/ai-elements/message";
export function ChatMessageParts({ message }: { message: UIMessage }) {
return (
<>
{message.parts.map((part, i) => {
const key = `${message.id}-${i}`;
switch (part.type) {
case "text":
return <MessageResponse key={key}>{part.text}</MessageResponse>;
default:
return null;
}
})}
</>
);
}استبدل .map المضمّن في page.tsx بـ <ChatMessageParts message={message} />. الآن كلّ نوع جزء جديد نربطه يُضيف case واحد هنا بدلاً من تفريع الصفحة.
الخطوة 6: بثّ التفكير
تُطلق نماذج Claude Sonnet 4.6 و GPT المزوّدة بالتفكير بثّ جزء reasoning منفصلاً قبل النصّ النهائي. يمنحك AI Elements لوحة قابلة للطيّ لذلك.
حدّث src/components/chat-message-parts.tsx:
"use client";
import type { UIMessage } from "ai";
import { MessageResponse } from "@/components/ai-elements/message";
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
type Props = {
message: UIMessage;
isLastMessage: boolean;
isStreaming: boolean;
};
export function ChatMessageParts({ message, isLastMessage, isStreaming }: Props) {
const reasoningParts = message.parts.filter((p) => p.type === "reasoning");
const reasoningText = reasoningParts.map((p) => p.text).join("\n\n");
const lastPart = message.parts.at(-1);
const isReasoningStreaming =
isLastMessage && isStreaming && lastPart?.type === "reasoning";
return (
<>
{reasoningParts.length > 0 && (
<Reasoning className="w-full" isStreaming={isReasoningStreaming}>
<ReasoningTrigger />
<ReasoningContent>{reasoningText}</ReasoningContent>
</Reasoning>
)}
{message.parts.map((part, i) => {
const key = `${message.id}-${i}`;
if (part.type === "text") {
return <MessageResponse key={key}>{part.text}</MessageResponse>;
}
return null;
})}
</>
);
}مرّر علامات البثّ من page.tsx:
{messages.map((message, index) => (
<Message from={message.role} key={message.id}>
<MessageContent>
<ChatMessageParts
message={message}
isLastMessage={index === messages.length - 1}
isStreaming={status === "streaming"}
/>
</MessageContent>
</Message>
))}يفتح Reasoning تلقائياً أثناء البثّ، ويُحرّك وميض الرموز، ويطوي نفسه عندما ينتهي النموذج من التفكير — سلوك الإغلاق التلقائي هذا هو الجزء الذي يُخطئ فيه الجميع عند بناء الواجهة يدوياً.
الخطوة 7: PromptInput مصقول بشريط أدوات
PromptInput المجرّد من الخطوة 4 جيّد، لكنّ القيمة الحقيقية لـ AI Elements هي بدائيات التخطيط التي تتألّف في شريط أدوات بنمط Claude. استبدل حقل الإدخال بتخطيط رأس-جسم-ذيل:
import {
PromptInput,
PromptInputBody,
PromptInputButton,
PromptInputFooter,
type PromptInputMessage,
PromptInputSelect,
PromptInputSelectContent,
PromptInputSelectItem,
PromptInputSelectTrigger,
PromptInputSelectValue,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from "@/components/ai-elements/prompt-input";
import { GlobeIcon } from "lucide-react";
const models = [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
];
// inside ChatPage:
const [model, setModel] = useState(models[0].id);
const [webSearch, setWebSearch] = useState(false);
const handleSubmit = (message: PromptInputMessage) => {
if (!message.text.trim() && !message.files?.length) return;
sendMessage(
{ text: message.text, files: message.files },
{ body: { model, webSearch } }
);
setInput("");
};
return (
// ...Conversation above...
<PromptInput onSubmit={handleSubmit} className="mt-4" globalDrop multiple>
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
placeholder="Ask anything..."
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputButton
onClick={() => setWebSearch(!webSearch)}
variant={webSearch ? "default" : "ghost"}
tooltip={{ content: "Search the web" }}
>
<GlobeIcon size={16} />
<span>Search</span>
</PromptInputButton>
<PromptInputSelect value={model} onValueChange={setModel}>
<PromptInputSelectTrigger>
<PromptInputSelectValue />
</PromptInputSelectTrigger>
<PromptInputSelectContent>
{models.map((m) => (
<PromptInputSelectItem key={m.id} value={m.id}>
{m.name}
</PromptInputSelectItem>
))}
</PromptInputSelectContent>
</PromptInputSelect>
</PromptInputTools>
<PromptInputSubmit
status={status === "streaming" ? "streaming" : "ready"}
/>
</PromptInputFooter>
</PromptInput>
);ثلاثة أمور حدثت للتوّ:
- معامل
bodyعلىsendMessageيُمرّر{ model, webSearch }إلى مُعالج المسار — هكذا تُملأ حقول الجسم في الخطوة 3 فعلياً. - يُبدّل
PromptInputSubmitتلقائياً بين أيقونة طائرة ورقية وزرّ إيقاف عندما يكونstatus === "streaming". النقر على متغيّر الإيقاف يستدعي إلغاءuseChatتحت الغطاء. - يُمكّن
globalDrop multipleالسحب والإفلات للمرفقات في أيّ مكان من الصفحة، وهو ما نُضيئه لاحقاً.
الخطوة 8: مرفقات الملفّات
يُقدّم AI Elements بدائيّة Attachments تتزاوج مع PromptInputActionAddAttachments. يُعرّض الخطّاف usePromptInputAttachments الحالة الحيّة للمرفقات.
أنشئ src/components/attachments-display.tsx:
"use client";
import {
Attachment,
AttachmentPreview,
AttachmentRemove,
Attachments,
} from "@/components/ai-elements/attachments";
import { usePromptInputAttachments } from "@/components/ai-elements/prompt-input";
export function AttachmentsDisplay() {
const attachments = usePromptInputAttachments();
if (attachments.files.length === 0) return null;
return (
<Attachments variant="inline">
{attachments.files.map((attachment) => (
<Attachment
data={attachment}
key={attachment.id}
onRemove={() => attachments.remove(attachment.id)}
>
<AttachmentPreview />
<AttachmentRemove />
</Attachment>
))}
</Attachments>
);
}الآن وسّع كتلة PromptInput في page.tsx:
import {
PromptInputActionAddAttachments,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputHeader,
} from "@/components/ai-elements/prompt-input";
import { AttachmentsDisplay } from "@/components/attachments-display";
// inside <PromptInput>:
<PromptInputHeader>
<AttachmentsDisplay />
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea ... />
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{/* ...web search button, model select... */}
</PromptInputTools>
<PromptInputSubmit ... />
</PromptInputFooter>أفلت ملفّ PNG أو PDF على الصفحة. ستراه يُعرَّض كشريحة فوق الحقل النصّي، وعند الإرسال يصل إلى message.files — والذي يُمرّره AI SDK 5 إلى النموذج كجزء file.
الخطوة 9: عرض استدعاءات الأدوات
عندما يستدعي النموذج أداة (أداة مخصّصة أو بحث ويب أو مُفسّر كود)، يُطلق البثّ أجزاء من نوع tool-<toolName>. يُقدّم AI Elements مكوّن Tool الذي يعرض اسم الاستدعاء والحالة والمُدخل والمُخرج كبطاقة مطويّة مرتّبة.
أضف إلى chat-message-parts.tsx:
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from "@/components/ai-elements/tool";
// inside the parts.map switch:
default: {
if (part.type.startsWith("tool-")) {
const toolPart = part as Extract<typeof part, { type: `tool-${string}` }>;
return (
<Tool key={key} defaultOpen={toolPart.state !== "output-available"}>
<ToolHeader type={toolPart.type} state={toolPart.state} />
<ToolContent>
<ToolInput input={toolPart.input} />
{toolPart.output !== undefined && (
<ToolOutput
output={toolPart.output}
errorText={toolPart.errorText}
/>
)}
</ToolContent>
</Tool>
);
}
return null;
}يُظهر ToolHeader كبسولة حالة — "input-streaming" و "input-available" و "output-available" و "output-error" — حتى يرى المستخدم الوكيل يُفكّر، لا واجهة مُتجمّدة.
الخطوة 10: مصادر بحث الويب
بمجرّد ضبط sendSources: true على استجابة المسار (الخطوة 3)، تبثّ النماذج القادرة على البحث في الويب أجزاء source-url يمكنك عرضها كذيل اقتباس.
import {
Source,
Sources,
SourcesContent,
SourcesTrigger,
} from "@/components/ai-elements/source";
// at the top of ChatMessageParts return:
const sourceParts = message.parts.filter((p) => p.type === "source-url");
{sourceParts.length > 0 && (
<Sources>
<SourcesTrigger count={sourceParts.length} />
<SourcesContent>
{sourceParts.map((s, i) => (
<Source
key={`${message.id}-src-${i}`}
href={s.url}
title={s.title ?? s.url}
/>
))}
</SourcesContent>
</Sources>
)}Sources لوحة قابلة للطيّ تحت ردّ المساعد بإدخال واحد لكلّ رابط مُقتبس. النقر على كلّ شريحة يفتح المصدر في علامة تبويب جديدة — النمط القياسي الذي شعبّته Perplexity.
اختبار التنفيذ
- أرسل "ما عاصمة تونس؟" — تأكّد أنّ المساعد يبثّ النصّ من الأعلى للأسفل دون اهتزاز التمرير.
- بدّل مُحدّد النموذج إلى Claude Sonnet 4.6 واسأل سؤال رياضيات — تأكّد أنّ لوحة التفكير تُفتح، وتتحرّك أثناء التفكير، وتنطوي عند الإكمال.
- شغّل زرّ Search واسأل سؤال أحداث جارية — تأكّد أنّ قائمة Sources المنسدلة تظهر تحت الإجابة.
- اسحب صورة صغيرة على الصفحة — تأكّد من ظهور شريحة مرفق، ثمّ أرسل وتحقّق من أنّ النموذج يُشير إلى الصورة في ردّه.
- أثناء البثّ، انقر زرّ الإيقاف على
PromptInputSubmit— تأكّد أنّ البثّ يتوقّف وأنّ الردّ الجزئي يبقى في مكانه.
استكشاف الأخطاء
Cannot find module '@/components/ai-elements/conversation' — لم يعمل CLI الخاصّ بـ AI Elements. أعد تشغيل npx ai-elements@latest من جذر مشروعك وتأكّد من ظهور الملفّات في src/components/ai-elements/.
لوحة التفكير لا تُفتح أبداً — مسارك يفتقر sendReasoning: true على toUIMessageStreamResponse، أو النموذج المُختار لا يُطلق تفكيراً. اختبر بـ claude-sonnet-4-6.
المرفقات تفشل بخطأ 413 — تقبل مسارات Next.js أجسام طلب تصل إلى 1 ميغابايت افتراضياً. أضف export const runtime = "nodejs" وارفع الحدّ في next.config.ts تحت experimental.serverActions.bodySizeLimit.
أخطاء نوع UIMessage بعد ترقية AI SDK — أعاد AI SDK 5 تسمية الحقل من content إلى parts. تأكّد أنّ إصدار @ai-sdk/react لديك 2.x على الأقلّ؛ تعتمد مكوّنات AI Elements على البنية القائمة على الأجزاء.
الخطوات التالية
- ازدوج هذه الواجهة مع درس Mastra AI agents لمنح الدردشة أدوات حقيقية وذاكرة ومحرّك سير عمل.
- أضف Resend + React Email لإرسال نسخة من المحادثة عند انتهائها.
- لُفّ الدردشة في شريط جانبي لـ CopilotKit بحيث يعيش بجانب تطبيقك بدلاً من احتلال الصفحة بأكملها.
- احفظ المحادثات في Drizzle + Neon مفتاحها المستخدم — لا علاقة لـ AI Elements بالتخزين، فالخيار لك.
الخاتمة
العمل المثير في تطبيق ذكاء اصطناعي هو حلقة الوكيل وتصميم المُوجّه والأدوات والتقييمات. الواجهة محلولة — لكن فقط إذا توقّفت عن حلّها بنفسك. AI Elements ليس إطاراً تتبنّاه، هو سجلّ تنسخ منه. بمجرّد أن تكون المكوّنات في مستودعك تصبح ملكك: أعد تنسيقها برموزك، وبدّل الأيقونات، واحذف أجزاء PromptInput التي لا تحتاجها، وانتقل إلى أجزاء منتجك التي تُميّزك فعلاً. أنفق سباقك على الوكيل، لا على سلوك التمرير التلقائي.