بناء محرك بحث دلالي بالذكاء الاصطناعي مع Next.js 15 و OpenAI و Pinecone

AI Bot
بواسطة AI Bot ·

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

البحث التقليدي القائم على الكلمات المفتاحية لم يعد كافيًا في عصر الذكاء الاصطناعي. عندما يبحث المستخدم عن "كيف أحسّن أداء تطبيقي"، فإن البحث التقليدي لن يجد مقالة بعنوان "تسريع واجهات React" رغم أنها الإجابة المثالية. هنا يأتي دور البحث الدلالي (Semantic Search) الذي يفهم المعنى وليس مجرد تطابق الحروف.

في هذا الدليل الشامل، سنبني محرك بحث دلالي كامل باستخدام:

  • Next.js 15 مع App Router و Server Actions
  • OpenAI Embeddings API لتحويل النصوص إلى متجهات (Vectors)
  • Pinecone كقاعدة بيانات متجهية سحابية
  • TypeScript لضمان أمان الأنواع

ما ستبنيه

تطبيق ويب يتيح للمستخدمين البحث في مجموعة من المقالات باستخدام البحث الدلالي. سيفهم التطبيق نية المستخدم ويعرض النتائج الأكثر صلة بالمعنى، حتى لو لم تتطابق الكلمات حرفيًا.

الميزات الأساسية:

  • واجهة بحث تفاعلية مع نتائج فورية
  • فهرسة المحتوى تلقائيًا عبر API Route
  • ترتيب النتائج حسب درجة التشابه الدلالي
  • دعم البحث بعدة لغات

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

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

  • Node.js 18 أو أحدث
  • معرفة أساسية بـ React و Next.js
  • حساب على OpenAI مع API Key
  • حساب على Pinecone (المستوى المجاني كافٍ)
  • محرر أكواد مثل VS Code

الخطوة 1: فهم البحث المتجهي

كيف يعمل البحث الدلالي؟

البحث المتجهي يعتمد على ثلاث مراحل:

  1. التضمين (Embedding): تحويل النص إلى متجه رقمي (مصفوفة أرقام) يمثّل معناه
  2. التخزين: حفظ المتجهات في قاعدة بيانات متخصصة مثل Pinecone
  3. الاستعلام: تحويل سؤال المستخدم إلى متجه ومقارنته مع المتجهات المخزنة

نموذج text-embedding-3-small من OpenAI ينتج متجهات بـ 1536 بُعدًا. كل بُعد يمثل جانبًا من معنى النص. النصوص المتشابهة في المعنى تكون متجهاتها قريبة من بعضها في الفضاء المتعدد الأبعاد.

المتجه (Vector) هو ببساطة مصفوفة أرقام. مثلاً: [0.023, -0.41, 0.87, ...] — كل رقم يمثل بُعدًا من أبعاد المعنى. القرب بين متجهين يعني تشابه المعنى.

الخطوة 2: إنشاء المشروع

أنشئ مشروع Next.js 15 جديد:

npx create-next-app@latest semantic-search-app --typescript --tailwind --app --src-dir
cd semantic-search-app

ثبّت المكتبات المطلوبة:

npm install openai @pinecone-database/pinecone

هيكل المشروع

src/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── api/
│   │   └── index-content/
│   │       └── route.ts
│   └── actions/
│       └── search.ts
├── lib/
│   ├── openai.ts
│   ├── pinecone.ts
│   └── types.ts
└── components/
    ├── SearchBar.tsx
    ├── SearchResults.tsx
    └── ArticleCard.tsx

الخطوة 3: إعداد متغيرات البيئة

أنشئ ملف .env.local في جذر المشروع:

OPENAI_API_KEY=sk-your-openai-api-key
PINECONE_API_KEY=your-pinecone-api-key
PINECONE_INDEX=semantic-search

لا تشارك مفاتيح API أبدًا. أضف .env.local إلى .gitignore (Next.js يفعل ذلك تلقائيًا).

الخطوة 4: إعداد OpenAI Client

أنشئ ملف src/lib/openai.ts:

import OpenAI from "openai";
 
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});
 
export async function generateEmbedding(text: string): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: text,
  });
 
  return response.data[0].embedding;
}
 
export async function generateEmbeddings(
  texts: string[]
): Promise<number[][]> {
  const response = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: texts,
  });
 
  return response.data.map((item) => item.embedding);
}

لماذا text-embedding-3-small؟

النموذجالأبعادالسعر لكل مليون رمزالأداء
text-embedding-3-small1536$0.02ممتاز للأغراض العامة
text-embedding-3-large3072$0.13أعلى دقة
text-embedding-ada-0021536$0.10الجيل السابق

النموذج الصغير يقدم توازنًا مثاليًا بين الأداء والتكلفة لمعظم التطبيقات.

الخطوة 5: إعداد Pinecone

إنشاء الفهرس على Pinecone

  1. سجّل الدخول إلى console.pinecone.io
  2. أنشئ فهرسًا جديدًا (Index) باسم semantic-search
  3. اختر 1536 كعدد الأبعاد (يطابق نموذج OpenAI)
  4. اختر cosine كمقياس التشابه

أنشئ ملف src/lib/pinecone.ts:

import { Pinecone } from "@pinecone-database/pinecone";
 
const pinecone = new Pinecone({
  apiKey: process.env.PINECONE_API_KEY!,
});
 
export const index = pinecone.index(process.env.PINECONE_INDEX!);
 
export interface ArticleMetadata {
  title: string;
  summary: string;
  url: string;
  category: string;
  language: string;
}
 
export async function upsertVectors(
  vectors: {
    id: string;
    values: number[];
    metadata: ArticleMetadata;
  }[]
) {
  // Pinecone يقبل 100 متجه كحد أقصى لكل عملية
  const batchSize = 100;
  for (let i = 0; i < vectors.length; i += batchSize) {
    const batch = vectors.slice(i, i + batchSize);
    await index.upsert(batch);
  }
}
 
export async function queryVectors(
  queryVector: number[],
  topK: number = 5,
  filter?: Record<string, string>
) {
  const results = await index.query({
    vector: queryVector,
    topK,
    includeMetadata: true,
    filter,
  });
 
  return results.matches || [];
}

الخطوة 6: تعريف الأنواع

أنشئ ملف src/lib/types.ts:

export interface Article {
  id: string;
  title: string;
  content: string;
  summary: string;
  url: string;
  category: string;
  language: string;
}
 
export interface SearchResult {
  id: string;
  score: number;
  title: string;
  summary: string;
  url: string;
  category: string;
}
 
export interface SearchState {
  results: SearchResult[];
  query: string;
  isLoading: boolean;
  error: string | null;
}

الخطوة 7: بناء API لفهرسة المحتوى

أنشئ ملف src/app/api/index-content/route.ts:

import { NextResponse } from "next/server";
import { generateEmbeddings } from "@/lib/openai";
import { upsertVectors, type ArticleMetadata } from "@/lib/pinecone";
import type { Article } from "@/lib/types";
 
// بيانات تجريبية — في التطبيق الحقيقي، اجلبها من CMS أو قاعدة بيانات
const articles: Article[] = [
  {
    id: "1",
    title: "تحسين أداء تطبيقات React",
    content:
      "دليل شامل لتحسين أداء تطبيقات React باستخدام memo و useMemo و useCallback والتقسيم الكسول للمكونات...",
    summary: "تعلّم تقنيات تحسين الأداء في React",
    url: "/tutorials/react-performance",
    category: "frontend",
    language: "ar",
  },
  {
    id: "2",
    title: "بناء REST API مع Node.js و Express",
    content:
      "كيفية تصميم وبناء واجهة برمجية RESTful متكاملة مع المصادقة والتحقق من البيانات...",
    summary: "بناء API متين مع Express.js",
    url: "/tutorials/nodejs-rest-api",
    category: "backend",
    language: "ar",
  },
  {
    id: "3",
    title: "أساسيات TypeScript للمطورين",
    content:
      "مقدمة شاملة في TypeScript تغطي الأنواع الأساسية والواجهات والأنواع المعممة...",
    summary: "ابدأ رحلتك مع TypeScript",
    url: "/tutorials/typescript-basics",
    category: "language",
    language: "ar",
  },
  {
    id: "4",
    title: "Deploying Next.js to Production",
    content:
      "Complete guide to deploying Next.js applications with Docker, CI/CD pipelines, and monitoring...",
    summary: "Production deployment strategies for Next.js",
    url: "/tutorials/nextjs-deployment",
    category: "devops",
    language: "en",
  },
  {
    id: "5",
    title: "Authentication with JWT and Refresh Tokens",
    content:
      "Implementing secure authentication using JSON Web Tokens with refresh token rotation...",
    summary: "Secure auth implementation guide",
    url: "/tutorials/jwt-auth",
    category: "security",
    language: "en",
  },
];
 
export async function POST() {
  try {
    // تحضير النصوص للتضمين: ندمج العنوان والمحتوى
    const textsToEmbed = articles.map(
      (article) => `${article.title}\n\n${article.content}`
    );
 
    // توليد المتجهات دفعة واحدة (أكثر كفاءة من طلبات فردية)
    const embeddings = await generateEmbeddings(textsToEmbed);
 
    // تحضير البيانات لـ Pinecone
    const vectors = articles.map((article, idx) => ({
      id: article.id,
      values: embeddings[idx],
      metadata: {
        title: article.title,
        summary: article.summary,
        url: article.url,
        category: article.category,
        language: article.language,
      } satisfies ArticleMetadata,
    }));
 
    // رفع المتجهات إلى Pinecone
    await upsertVectors(vectors);
 
    return NextResponse.json({
      success: true,
      indexed: articles.length,
    });
  } catch (error) {
    console.error("Indexing error:", error);
    return NextResponse.json(
      { error: "Failed to index content" },
      { status: 500 }
    );
  }
}

الخطوة 8: بناء Server Action للبحث

أنشئ ملف src/app/actions/search.ts:

"use server";
 
import { generateEmbedding } from "@/lib/openai";
import { queryVectors } from "@/lib/pinecone";
import type { SearchResult } from "@/lib/types";
 
export async function semanticSearch(
  query: string,
  language?: string
): Promise<SearchResult[]> {
  if (!query.trim()) {
    return [];
  }
 
  try {
    // تحويل استعلام المستخدم إلى متجه
    const queryVector = await generateEmbedding(query);
 
    // تحضير الفلتر حسب اللغة (اختياري)
    const filter = language ? { language } : undefined;
 
    // البحث في Pinecone
    const matches = await queryVectors(queryVector, 5, filter);
 
    // تحويل النتائج إلى الشكل المطلوب
    const results: SearchResult[] = matches.map((match) => ({
      id: match.id,
      score: match.score || 0,
      title: (match.metadata?.title as string) || "",
      summary: (match.metadata?.summary as string) || "",
      url: (match.metadata?.url as string) || "",
      category: (match.metadata?.category as string) || "",
    }));
 
    return results;
  } catch (error) {
    console.error("Search error:", error);
    throw new Error("فشل البحث. يرجى المحاولة مرة أخرى.");
  }
}

استخدام Server Actions يعني أن مفاتيح API تبقى على الخادم فقط ولا تُرسل أبدًا إلى المتصفح. هذا أكثر أمانًا من إنشاء API Route منفصل للبحث.

الخطوة 9: بناء مكوّن شريط البحث

أنشئ ملف src/components/SearchBar.tsx:

"use client";
 
import { useState, useTransition, useCallback } from "react";
import { semanticSearch } from "@/app/actions/search";
import type { SearchResult } from "@/lib/types";
 
interface SearchBarProps {
  onResults: (results: SearchResult[]) => void;
  onLoading: (loading: boolean) => void;
}
 
export default function SearchBar({ onResults, onLoading }: SearchBarProps) {
  const [query, setQuery] = useState("");
  const [isPending, startTransition] = useTransition();
 
  const handleSearch = useCallback(() => {
    if (!query.trim()) return;
 
    onLoading(true);
    startTransition(async () => {
      try {
        const results = await semanticSearch(query);
        onResults(results);
      } catch {
        onResults([]);
      } finally {
        onLoading(false);
      }
    });
  }, [query, onResults, onLoading]);
 
  return (
    <div className="w-full max-w-2xl mx-auto">
      <div className="relative">
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleSearch()}
          placeholder="ابحث بالمعنى... مثال: كيف أحسّن سرعة تطبيقي؟"
          className="w-full px-6 py-4 text-lg border-2 border-gray-200
                     rounded-2xl focus:border-blue-500 focus:outline-none
                     transition-colors duration-200 pr-14"
          dir="rtl"
        />
        <button
          onClick={handleSearch}
          disabled={isPending || !query.trim()}
          className="absolute left-3 top-1/2 -translate-y-1/2
                     bg-blue-600 text-white px-4 py-2 rounded-xl
                     hover:bg-blue-700 disabled:opacity-50
                     disabled:cursor-not-allowed transition-colors"
        >
          {isPending ? "..." : "بحث"}
        </button>
      </div>
      <p className="text-sm text-gray-500 mt-2 text-right">
        البحث الدلالي يفهم المعنى — جرّب أسئلة طبيعية بدلاً من كلمات مفتاحية
      </p>
    </div>
  );
}

الخطوة 10: بناء مكوّن عرض النتائج

أنشئ ملف src/components/ArticleCard.tsx:

import type { SearchResult } from "@/lib/types";
 
interface ArticleCardProps {
  result: SearchResult;
}
 
export default function ArticleCard({ result }: ArticleCardProps) {
  // تحويل النسبة إلى درجة مئوية
  const relevancePercent = Math.round(result.score * 100);
 
  return (
    <a
      href={result.url}
      className="block p-6 bg-white rounded-2xl border border-gray-100
                 hover:border-blue-200 hover:shadow-lg transition-all
                 duration-200"
    >
      <div className="flex items-start justify-between gap-4">
        <div className="flex-1">
          <h3 className="text-xl font-bold text-gray-900 mb-2">
            {result.title}
          </h3>
          <p className="text-gray-600 leading-relaxed">{result.summary}</p>
          <span className="inline-block mt-3 text-sm text-blue-600
                          bg-blue-50 px-3 py-1 rounded-full">
            {result.category}
          </span>
        </div>
        <div className="flex-shrink-0 text-center">
          <div
            className={`text-2xl font-bold ${
              relevancePercent >= 80
                ? "text-green-600"
                : relevancePercent >= 60
                ? "text-yellow-600"
                : "text-gray-400"
            }`}
          >
            {relevancePercent}%
          </div>
          <div className="text-xs text-gray-400">تطابق</div>
        </div>
      </div>
    </a>
  );
}

أنشئ ملف src/components/SearchResults.tsx:

import type { SearchResult } from "@/lib/types";
import ArticleCard from "./ArticleCard";
 
interface SearchResultsProps {
  results: SearchResult[];
  isLoading: boolean;
}
 
export default function SearchResults({
  results,
  isLoading,
}: SearchResultsProps) {
  if (isLoading) {
    return (
      <div className="space-y-4 mt-8">
        {[1, 2, 3].map((i) => (
          <div
            key={i}
            className="h-32 bg-gray-100 rounded-2xl animate-pulse"
          />
        ))}
      </div>
    );
  }
 
  if (results.length === 0) {
    return null;
  }
 
  return (
    <div className="space-y-4 mt-8">
      <p className="text-sm text-gray-500">
        تم العثور على {results.length} نتائج مرتبة حسب الصلة بالمعنى
      </p>
      {results.map((result) => (
        <ArticleCard key={result.id} result={result} />
      ))}
    </div>
  );
}

الخطوة 11: تجميع الصفحة الرئيسية

عدّل ملف src/app/page.tsx:

"use client";
 
import { useState } from "react";
import SearchBar from "@/components/SearchBar";
import SearchResults from "@/components/SearchResults";
import type { SearchResult } from "@/lib/types";
 
export default function Home() {
  const [results, setResults] = useState<SearchResult[]>([]);
  const [isLoading, setIsLoading] = useState(false);
 
  return (
    <main className="min-h-screen bg-gradient-to-b from-gray-50 to-white">
      <div className="max-w-4xl mx-auto px-4 py-20">
        <div className="text-center mb-12">
          <h1 className="text-4xl font-bold text-gray-900 mb-4">
            البحث الدلالي بالذكاء الاصطناعي
          </h1>
          <p className="text-xl text-gray-600">
            ابحث بالمعنى، وليس بالكلمات فقط
          </p>
        </div>
 
        <SearchBar onResults={setResults} onLoading={setIsLoading} />
        <SearchResults results={results} isLoading={isLoading} />
      </div>
    </main>
  );
}

الخطوة 12: فهرسة المحتوى

قبل أن يعمل البحث، نحتاج لفهرسة المقالات. شغّل الخادم ثم نفّذ طلب الفهرسة:

npm run dev

في نافذة طرفية أخرى:

curl -X POST http://localhost:3000/api/index-content

يجب أن تحصل على:

{
  "success": true,
  "indexed": 5
}

الخطوة 13: اختبار البحث الدلالي

افتح المتصفح على http://localhost:3000 وجرّب هذه الاستعلامات:

الاستعلامالنتيجة المتوقعة
"كيف أسرّع تطبيقي"مقالة تحسين أداء React
"أريد حماية تطبيقي"مقالة JWT والمصادقة
"how to deploy"مقالة نشر Next.js
"أريد تعلم لغة برمجة"مقالة TypeScript

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

الخطوة 14: تحسينات متقدمة

إضافة Debounce للبحث التلقائي

لتحسين تجربة المستخدم، أضف بحثًا تلقائيًا أثناء الكتابة:

// src/hooks/useDebounce.ts
import { useEffect, useState } from "react";
 
export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
 
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
 
    return () => clearTimeout(handler);
  }, [value, delay]);
 
  return debouncedValue;
}

التخزين المؤقت للنتائج

أضف تخزينًا مؤقتًا على مستوى الخادم لتقليل استدعاءات OpenAI:

// src/lib/cache.ts
const cache = new Map<string, { data: number[]; timestamp: number }>();
const TTL = 1000 * 60 * 60; // ساعة واحدة
 
export function getCachedEmbedding(text: string): number[] | null {
  const entry = cache.get(text);
  if (!entry) return null;
 
  if (Date.now() - entry.timestamp > TTL) {
    cache.delete(text);
    return null;
  }
 
  return entry.data;
}
 
export function setCachedEmbedding(text: string, embedding: number[]) {
  cache.set(text, { data: embedding, timestamp: Date.now() });
}

ثم عدّل دالة generateEmbedding:

export async function generateEmbedding(text: string): Promise<number[]> {
  // تحقق من الكاش أولاً
  const cached = getCachedEmbedding(text);
  if (cached) return cached;
 
  const response = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: text,
  });
 
  const embedding = response.data[0].embedding;
  setCachedEmbedding(text, embedding);
 
  return embedding;
}

تصفية النتائج حسب البيانات الوصفية

Pinecone يدعم التصفية حسب البيانات الوصفية (Metadata Filtering). يمكنك دمجها مع البحث المتجهي:

// البحث في مقالات الـ frontend فقط
const results = await queryVectors(queryVector, 5, {
  category: "frontend",
});
 
// البحث في المقالات العربية فقط
const results = await queryVectors(queryVector, 5, {
  language: "ar",
});

الخطوة 15: النشر على الإنتاج

إعداد Vercel

npm install -g vercel
vercel

أضف متغيرات البيئة في لوحة تحكم Vercel:

  • OPENAI_API_KEY
  • PINECONE_API_KEY
  • PINECONE_INDEX

اعتبارات الإنتاج

  1. حدود الاستخدام: أضف Rate Limiting لحماية API من الإساءة
  2. المراقبة: تتبّع استدعاءات OpenAI وتكلفتها
  3. التخزين المؤقت: استخدم Redis لتخزين المتجهات المتكررة
  4. التحديث: أنشئ Cron Job لإعادة فهرسة المحتوى دوريًا
// مثال: Rate Limiting بسيط
const rateLimitMap = new Map<string, number[]>();
 
function isRateLimited(ip: string, maxRequests = 10, windowMs = 60000) {
  const now = Date.now();
  const requests = rateLimitMap.get(ip) || [];
  const recentRequests = requests.filter((t) => now - t < windowMs);
 
  if (recentRequests.length >= maxRequests) {
    return true;
  }
 
  recentRequests.push(now);
  rateLimitMap.set(ip, recentRequests);
  return false;
}

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

المشكلات الشائعة

خطأ في مفتاح OpenAI: تأكد من صحة المفتاح وأن لديك رصيدًا كافيًا. تحقق من ملف .env.local.

فهرس Pinecone لا يستجيب: تأكد من أن عدد الأبعاد في الفهرس يطابق 1536 (لنموذج text-embedding-3-small).

نتائج غير دقيقة:

  • أضف المزيد من المحتوى للفهرسة — كلما زادت البيانات، تحسنت النتائج
  • جرّب دمج العنوان والمحتوى والوسوم معًا قبل التضمين
  • استخدم text-embedding-3-large لدقة أعلى

بطء الاستجابة:

  • فعّل التخزين المؤقت للمتجهات المتكررة
  • استخدم Debounce لتقليل الاستعلامات أثناء الكتابة
  • اختر منطقة Pinecone الأقرب لخادمك

تقدير التكلفة

المكوّنالتكلفةملاحظات
OpenAI Embeddingsحوالي $0.02 لكل مليون رمزرخيص جدًا
Pineconeمجاني حتى 100,000 متجهالمستوى المجاني كافٍ للبداية
Vercelمجاني للمشاريع الشخصيةHobby plan

لتطبيق بـ 1000 مقالة و 10,000 عملية بحث شهريًا، التكلفة التقديرية أقل من $1 شهريًا.

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

بعد إتمام هذا الدليل، يمكنك:

  • إضافة RAG (Retrieval-Augmented Generation): اجمع نتائج البحث مع نموذج LLM لتوليد إجابات مخصصة
  • بناء بحث متعدد الوسائط: أضف بحث الصور باستخدام نماذج CLIP
  • إضافة تحليلات البحث: تتبّع ما يبحث عنه المستخدمون لتحسين المحتوى
  • دمج مع CMS: اربط الفهرسة تلقائيًا مع نظام إدارة المحتوى

الخلاصة

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

في هذا الدليل، تعلّمت:

  • كيف تعمل المتجهات والتضمينات (Embeddings)
  • إعداد OpenAI و Pinecone للبحث المتجهي
  • بناء Server Actions آمنة للبحث
  • تصميم واجهة بحث تفاعلية
  • تحسينات الأداء والإنتاج

التقنيات التي استخدمناها — OpenAI Embeddings و Pinecone و Next.js Server Actions — تمثل الطريقة الحديثة لبناء تطبيقات بحث ذكية وقابلة للتوسع.


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

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

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

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

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

بناء وكلاء الذكاء الاصطناعي من الصفر باستخدام TypeScript: إتقان نمط ReAct مع Vercel AI SDK

تعلّم كيفية بناء وكلاء الذكاء الاصطناعي من الأساس باستخدام TypeScript. يغطي هذا الدليل التعليمي نمط ReAct، واستدعاء الأدوات، والاستدلال متعدد الخطوات، وحلقات الوكلاء الجاهزة للإنتاج مع Vercel AI SDK.

35 د قراءة·