في هذا المثال ، سنقوم ببناء تطبيق كامل المكاسب يستخدم الجيل المعزز للاسترجاع (RAG) مدعوم من Pinecone لتقديم استجابات دقيقة وذات صلة بالسياق في chatbot.
RAG هي أداة قوية تجمع بين فوائد النماذج القائمة على الاسترجاع والنماذج التوليدية. على عكس chatbots التقليدية التي يمكن أن تكافح من خلال الحفاظ على المعلومات المحدثة أو الوصول إلى المعرفة الخاصة بالمجال ، يستخدم chatbot القائمة على القطع قاعدة معارف تم إنشاؤها من عناوين URL المزروعة لتوفير استجابات ذات صلة بالسياق.
سيتيح لنا دمج AI SDK من Vercel في تطبيقنا إعداد سير عمل chatbot بسهولة والاستفادة من البث بشكل أكثر كفاءة ، وخاصة في بيئات الحافة ، وتعزيز استجابة وأداء chatbot لدينا.
بحلول نهاية هذا البرنامج التعليمي ، سيكون لديك chatbot المدرك في السياق الذي يوفر استجابات دقيقة بدون هلوسة ، مما يضمن تجربة مستخدم أكثر فاعلية وجذابة. دعنا نبدأ في بناء هذه الأداة القوية (قائمة الكود الكامل).
Next.js هو إطار JavaScript قوي يمكّننا من إنشاء تطبيقات الويب المقدمة من جانب الخادم باستخدام React. إنه خيار رائع لمشروعنا نظرًا لسهولة الإعداد والأداء الممتاز والميزات المدمجة مثل طرق التوجيه وطرق API.
لإنشاء تطبيق NEXT.JS جديد ، قم بتشغيل الأمر التالي:
npx create-next-app chatbot
بعد ذلك ، سنضيف حزمة ai
:
npm install ai
يمكنك استخدام قائمة التبعيات الكاملة إذا كنت ترغب في الإنشاء مع البرنامج التعليمي.
في هذه الخطوة ، سنستخدم Vercel SDK لإنشاء الواجهة الخلفية والواجهة من chatbot لدينا في تطبيق Next.js. بحلول نهاية هذه الخطوة ، سيتم تشغيل chatbot الأساسي لدينا ، جاهزًا لنا لإضافة إمكانيات مدركة للسياق في المراحل التالية. لنبدأ.
الآن ، دعونا نركز على المكون الأمامي في chatbot لدينا. سنقوم بإنشاء عناصر تواجه المستخدم في روبوتنا ، وإنشاء الواجهة التي يتفاعل من خلالها المستخدمين مع تطبيقنا. سيتضمن ذلك صياغة تصميم ووظائف واجهة الدردشة ضمن تطبيق Next.js الخاص بنا.
أولاً ، سنقوم بإنشاء مكون Chat
، الذي سيجعل واجهة الدردشة.
import React , { FormEvent , ChangeEvent } from "react" ;
import Messages from "./Messages" ;
import { Message } from "ai/react" ;
interface Chat {
input : string ;
handleInputChange : ( e : ChangeEvent < HTMLInputElement > ) => void ;
handleMessageSubmit : ( e : FormEvent < HTMLFormElement > ) => Promise < void > ;
messages : Message [ ] ;
}
const Chat : React . FC < Chat > = ( {
input ,
handleInputChange ,
handleMessageSubmit ,
messages ,
} ) => {
return (
< div id = "chat" className = "..." >
< Messages messages = { messages } / >
< >
< form onSubmit = { handleMessageSubmit } className = "..." >
< input
type = "text"
className = "..."
value = { input }
onChange = { handleInputChange }
/ >
< span className = "..." > Press ⮐ to send < / span >
< / form >
< / >
< / div >
) ;
} ;
export default Chat ;
سيعرض هذا المكون قائمة الرسائل ونموذج الإدخال للمستخدم لإرسال الرسائل. مكون Messages
لتقديم رسائل الدردشة:
import { Message } from "ai" ;
import { useRef } from "react" ;
export default function Messages ( { messages } : { messages : Message [ ] } ) {
const messagesEndRef = useRef < HTMLDivElement | null > ( null ) ;
return (
< div className = "..." >
{ messages . map ( ( msg , index ) => (
< div
key = { index }
className = { ` ${
msg . role === "assistant" ? "text-green-300" : "text-blue-300"
} ... ` }
>
< div className = "..." > { msg . role === "assistant" ? "?" : "?" } < / div >
< div className = "..." > { msg . content } < / div >
< / div >
) ) }
< div ref = { messagesEndRef } / >
< / div >
) ;
}
سيقوم مكوننا الرئيسي Page
بإدارة الحالة للرسائل المعروضة في مكون Chat
:
"use client" ;
import Header from "@/components/Header" ;
import Chat from "@/components/Chat" ;
import { useChat } from "ai/react" ;
const Page : React . FC = ( ) => {
const [ context , setContext ] = useState < string [ ] | null > ( null ) ;
const { messages , input , handleInputChange , handleSubmit } = useChat ( ) ;
return (
< div className = "..." >
< Header className = "..." / >
< div className = "..." >
< Chat
input = { input }
handleInputChange = { handleInputChange }
handleMessageSubmit = { handleSubmit }
messages = { messages }
/ >
< / div >
< / div >
) ;
} ;
export default Page ;
سيقوم خطاف useChat
المفيد بإدارة الحالة للرسائل المعروضة في مكون Chat
. فإنه سوف:
بعد ذلك ، سنقوم بإعداد نقطة نهاية chatbot API. هذا هو المكون من جانب الخادم الذي سيتعامل مع الطلبات والاستجابات لـ chatbot لدينا. سنقوم بإنشاء ملف جديد يسمى api/chat/route.ts
وإضافة التبعيات التالية:
import { Configuration , OpenAIApi } from "openai-edge" ;
import { Message , OpenAIStream , StreamingTextResponse } from "ai" ;
التبعية الأولى هي حزمة openai-edge
التي تجعل من السهل التفاعل مع واجهات برمجة تطبيقات Openai في بيئة الحافة. التبعية الثانية هي حزمة ai
التي سنستخدمها لتحديد Message
وأنواع OpenAIStream
، والتي سنستخدمها لدفق الاستجابة من Openai إلى العميل.
تهيئة عميل Openai التالي التالي:
// Create an OpenAI API client (that's edge friendly!)
const config = new Configuration ( {
apiKey : process . env . OPENAI_API_KEY ,
} ) ;
const openai = new OpenAIApi ( config ) ;
لتحديد نقطة النهاية هذه كدالة حافة ، سنقوم بتحديد وتصدير متغير runtime
export const runtime = "edge" ;
بعد ذلك ، سنحدد معالج نقطة النهاية:
export async function POST ( req : Request ) {
try {
const { messages } = await req . json ( ) ;
const prompt = [
{
role : "system" ,
content : `AI assistant is a brand new, powerful, human-like artificial intelligence.
The traits of AI include expert knowledge, helpfulness, cleverness, and articulateness.
AI is a well-behaved and well-mannered individual.
AI is always friendly, kind, and inspiring, and he is eager to provide vivid and thoughtful responses to the user.
AI has the sum of all knowledge in their brain, and is able to accurately answer nearly any question about any topic in conversation.
AI assistant is a big fan of Pinecone and Vercel.
` ,
} ,
] ;
// Ask OpenAI for a streaming chat completion given the prompt
const response = await openai . createChatCompletion ( {
model : "gpt-3.5-turbo" ,
stream : true ,
messages : [
... prompt ,
... messages . filter ( ( message : Message ) => message . role === "user" ) ,
] ,
} ) ;
// Convert the response into a friendly text-stream
const stream = OpenAIStream ( response ) ;
// Respond with the stream
return new StreamingTextResponse ( stream ) ;
} catch ( e ) {
throw e ;
}
}
هنا نفكك الرسائل من المنشور ، وننشئ موجه الأولي الخاص بنا. نستخدم المطالبة والرسائل كإدخال إلى طريقة createChatCompletion
. ثم نقوم بتحويل الاستجابة إلى دفق وإعادته إلى العميل. لاحظ أنه في هذا المثال ، نرسل رسائل المستخدم فقط إلى Openai (على عكس تضمين رسائل الروبوت أيضًا).
أثناء الغوص في بناء chatbot لدينا ، من المهم أن نفهم دور السياق. تعد إضافة سياق إلى استجابات chatbot لدينا مفتاح إنشاء تجربة مستخدم أكثر طبيعية ومحادثة. بدون سياق ، يمكن أن تشعر ردود chatbot بأنها مفككة أو غير ذات صلة. من خلال فهم سياق استعلام المستخدم ، سيكون chatbot لدينا قادرًا على توفير ردود أكثر دقة وذات صلة وجذابة. الآن ، لنبدأ في البناء مع وضع هذا الهدف في الاعتبار.
أولاً ، سوف نركز أولاً على بذر قاعدة المعرفة. سنقوم بإنشاء زاحف ونص برمجي بذور ، وننشئ نقطة نهاية الزحف. سيتيح لنا ذلك جمع وتنظيم المعلومات التي سيستخدمها chatbot لدينا لتوفير استجابات ذات صلة بالسياق.
بعد أن نسأت قاعدة معارفنا ، سنسترجع المباريات من تضميناتنا. سيمكن ذلك chatbot من العثور على المعلومات ذات الصلة بناءً على استعلامات المستخدم.
بعد ذلك ، سنلف منطقنا في وظيفة getContext ونقوم بتحديث موجه chatbot الخاص بنا. سيؤدي ذلك إلى تبسيط رمزنا وتحسين تجربة المستخدم من خلال ضمان أن مطالبات chatbot ذات صلة وجذابة.
أخيرًا ، سنضيف لوحة سياق ونقطة نهاية سياق مرتبطة. ستوفر هذه واجهة مستخدم لـ chatbot وطريقة لاسترداد السياق اللازم لكل استعلام للمستخدم.
هذه الخطوة تدور حول تغذية chatbot لدينا المعلومات التي يحتاجها وإعداد البنية التحتية اللازمة لها لاسترداد هذه المعلومات واستخدامها بفعالية. لنبدأ.
الآن سننتقل إلى زرع قاعدة المعرفة ، مصدر البيانات التأسيسي الذي سيبلغ استجابات chatbot لدينا. تتضمن هذه الخطوة جمع وتنظيم المعلومات التي يحتاجها chatbot إلى العمل بفعالية. في هذا الدليل ، سنستخدم البيانات التي تم استردادها من مواقع الويب المختلفة التي سنكون قادرين لاحقًا على طرح أسئلة حولها. للقيام بذلك ، سنقوم بإنشاء زاحف من شأنه أن يتخلص من البيانات من مواقع الويب ، وتضمينها ، وتخزينها في Pinecone.
من أجل الإيجاز ، ستتمكن من العثور على الكود الكامل للزاحف هنا. فيما يلي الأجزاء ذات الصلة:
class Crawler {
private seen = new Set < string > ( ) ;
private pages : Page [ ] = [ ] ;
private queue : { url : string ; depth : number } [ ] = [ ] ;
constructor ( private maxDepth = 2 , private maxPages = 1 ) { }
async crawl ( startUrl : string ) : Promise < Page [ ] > {
// Add the start URL to the queue
this . addToQueue ( startUrl ) ;
// While there are URLs in the queue and we haven't reached the maximum number of pages...
while ( this . shouldContinueCrawling ( ) ) {
// Dequeue the next URL and depth
const { url , depth } = this . queue . shift ( ) ! ;
// If the depth is too great or we've already seen this URL, skip it
if ( this . isTooDeep ( depth ) || this . isAlreadySeen ( url ) ) continue ;
// Add the URL to the set of seen URLs
this . seen . add ( url ) ;
// Fetch the page HTML
const html = await this . fetchPage ( url ) ;
// Parse the HTML and add the page to the list of crawled pages
this . pages . push ( { url , content : this . parseHtml ( html ) } ) ;
// Extract new URLs from the page HTML and add them to the queue
this . addNewUrlsToQueue ( this . extractUrls ( html , url ) , depth ) ;
}
// Return the list of crawled pages
return this . pages ;
}
// ... Some private methods removed for brevity
private async fetchPage ( url : string ) : Promise < string > {
try {
const response = await fetch ( url ) ;
return await response . text ( ) ;
} catch ( error ) {
console . error ( `Failed to fetch ${ url } : ${ error } ` ) ;
return "" ;
}
}
private parseHtml ( html : string ) : string {
const $ = cheerio . load ( html ) ;
$ ( "a" ) . removeAttr ( "href" ) ;
return NodeHtmlMarkdown . translate ( $ . html ( ) ) ;
}
private extractUrls ( html : string , baseUrl : string ) : string [ ] {
const $ = cheerio . load ( html ) ;
const relativeUrls = $ ( "a" )
. map ( ( _ , link ) => $ ( link ) . attr ( "href" ) )
. get ( ) as string [ ] ;
return relativeUrls . map (
( relativeUrl ) => new URL ( relativeUrl , baseUrl ) . href
) ;
}
}
فئة Crawler
عبارة عن زاحف على شبكة الإنترنت يزور عناوين URL ، بدءًا من نقطة معينة ، ويجمع المعلومات منها. يعمل ضمن عمق معين وعدد أقصى للصفحات كما هو محدد في المُنشئ. طريقة الزحف هي الوظيفة الأساسية التي تبدأ عملية الزحف.
تعامل أساليب المساعد ، و parsehtml ، و extracturls على التوالي مع جلب محتوى HTML للصفحة ، وتوحل HTML لاستخراج النص ، واستخراج جميع عناوين URL من صفحة ليتم طابورها للزحف التالي. يحتفظ الفصل أيضًا بسجل من عناوين URL التي تمت زيارتها لتجنب الازدواجية.
seed
لربط الأشياء معًا ، سنقوم بإنشاء وظيفة البذور التي ستستخدم الزاحف لبذرة قاعدة المعرفة. في هذا الجزء من الكود ، سنقوم بتهيئة الزحف ونحضر عنوان URL معين ، ثم قم بتقسيم محتوىه إلى قطع ، وأخيراً تم تضمينها وفهرستها في Pinecone.
async function seed ( url : string , limit : number , indexName : string , options : SeedOptions ) {
try {
// Initialize the Pinecone client
const pinecone = new Pinecone ( ) ;
// Destructure the options object
const { splittingMethod , chunkSize , chunkOverlap } = options ;
// Create a new Crawler with depth 1 and maximum pages as limit
const crawler = new Crawler ( 1 , limit || 100 ) ;
// Crawl the given URL and get the pages
const pages = await crawler . crawl ( url ) as Page [ ] ;
// Choose the appropriate document splitter based on the splitting method
const splitter : DocumentSplitter = splittingMethod === 'recursive' ?
new RecursiveCharacterTextSplitter ( { chunkSize , chunkOverlap } ) : new MarkdownTextSplitter ( { } ) ;
// Prepare documents by splitting the pages
const documents = await Promise . all ( pages . map ( page => prepareDocument ( page , splitter ) ) ) ;
// Create Pinecone index if it does not exist
const indexList = await pinecone . listIndexes ( ) ;
const indexExists = indexList . some ( index => index . name === indexName )
if ( ! indexExists ) {
await pinecone . createIndex ( {
name : indexName ,
dimension : 1536 ,
waitUntilReady : true ,
} ) ;
}
const index = pinecone . Index ( indexName )
// Get the vector embeddings for the documents
const vectors = await Promise . all ( documents . flat ( ) . map ( embedDocument ) ) ;
// Upsert vectors into the Pinecone index
await chunkedUpsert ( index ! , vectors , '' , 10 ) ;
// Return the first document
return documents [ 0 ] ;
} catch ( error ) {
console . error ( "Error seeding:" , error ) ;
throw error ;
}
}
لتكوين المحتوى ، سنستخدم إحدى الطرق التالية:
RecursiveCharacterTextSplitter
- يقسم هذا الخائن النص إلى أجزاء من حجم معين ، ثم يقسم القطع بشكل متكرر إلى قطع أصغر حتى يتم الوصول إلى حجم القطعة. هذه الطريقة مفيدة للوثائق الطويلة.MarkdownTextSplitter
- يقسم هذا الخائن النص إلى أجزاء بناءً على رؤوس مقلوبة. هذه الطريقة مفيدة للوثائق التي تم تنظيمها بالفعل باستخدام Markdown. تتمثل فائدة هذه الطريقة في أنها ستقسم المستند إلى أجزاء بناءً على الرؤوس ، والتي ستكون مفيدة لـ chatbot لفهم بنية المستند. يمكننا أن نفترض أن كل وحدة من النصوص تحت رأس هي وحدة معلومات متماسكة داخليًا ، وعندما يسأل المستخدم سؤالًا ، سيكون السياق الذي تم استرداده متماسكًا داخليًا أيضًا.crawl
نقطة النهاية لنقطة نهاية crawl
واضحة ومباشرة. إنه ببساطة يدعو وظيفة seed
ويعيد النتيجة.
import seed from "./seed" ;
import { NextResponse } from "next/server" ;
export const runtime = "edge" ;
export async function POST ( req : Request ) {
const { url , options } = await req . json ( ) ;
try {
const documents = await seed ( url , 1 , process . env . PINECONE_INDEX ! , options ) ;
return NextResponse . json ( { success : true , documents } ) ;
} catch ( error ) {
return NextResponse . json ( { success : false , error : "Failed crawling" } ) ;
}
}
الآن ، فإن الواجهة الخلفية لدينا قادرة على زحف عنوان URL معين ، وتضمين المحتوى وفهرسة التضمين في Pinecone. ستعيد نقطة النهاية جميع الأجزاء في صفحة الويب التي تم استردادها التي نزحفها ، لذلك سنكون قادرين على عرضها. بعد ذلك ، سنكتب مجموعة من الوظائف التي من شأنها أن تبني السياق خارج هذه التضمينات.
لاسترداد المستندات الأكثر صلة من الفهرس ، سنستخدم وظيفة query
في Pinecone SDK. تأخذ هذه الوظيفة متجهًا وإرجاع أكثر المتجهات مماثلة من الفهرس. سنستخدم هذه الوظيفة لاسترداد المستندات الأكثر صلة من الفهرس ، بالنظر إلى بعض التضمين.
const getMatchesFromEmbeddings = async ( embeddings : number [ ] , topK : number , namespace : string ) : Promise < ScoredPineconeRecord < Metadata > [ ] > => {
// Obtain a client for Pinecone
const pinecone = new Pinecone ( ) ;
const indexName : string = process . env . PINECONE_INDEX || '' ;
if ( indexName === '' ) {
throw new Error ( 'PINECONE_INDEX environment variable not set' )
}
// Retrieve the list of indexes to check if expected index exists
const indexes = await pinecone . listIndexes ( )
if ( indexes . filter ( i => i . name === indexName ) . length !== 1 ) {
throw new Error ( `Index ${ indexName } does not exist` )
}
// Get the Pinecone index
const index = pinecone ! . Index < Metadata > ( indexName ) ;
// Get the namespace
const pineconeNamespace = index . namespace ( namespace ?? '' )
try {
// Query the index with the defined request
const queryResult = await pineconeNamespace . query ( {
vector : embeddings ,
topK ,
includeMetadata : true ,
} )
return queryResult . matches || [ ]
} catch ( e ) {
// Log the error and throw it
console . log ( "Error querying embeddings: " , e )
throw new Error ( `Error querying embeddings: ${ e } ` )
}
}
تأخذ الوظيفة في التضمينات ، ومعلمة topk ، ومساحة الاسم ، وإرجاع مباريات topk من فهرس Pinecone. يحصل أولاً على عميل Pinecone ، ويتحقق مما إذا كان الفهرس المطلوب موجودًا في قائمة الفهارس ، ويرمي خطأ إذا لم يكن كذلك. ثم يحصل على مؤشر Pinecone محدد. ثم تستفسر الوظيفة من فهرس Pinecone مع الطلب المحدد وإرجاع المباريات.
getContext
سنلف الأشياء معًا في وظيفة getContext
. ستأخذ هذه الوظيفة message
وإرجاع السياق - إما في نموذج السلسلة ، أو كمجموعة من ScoredVector
.
export const getContext = async (
message : string ,
namespace : string ,
maxTokens = 3000 ,
minScore = 0.7 ,
getOnlyText = true
) : Promise < string | ScoredVector [ ] > => {
// Get the embeddings of the input message
const embedding = await getEmbeddings ( message ) ;
// Retrieve the matches for the embeddings from the specified namespace
const matches = await getMatchesFromEmbeddings ( embedding , 3 , namespace ) ;
// Filter out the matches that have a score lower than the minimum score
const qualifyingDocs = matches . filter ( ( m ) => m . score && m . score > minScore ) ;
// If the `getOnlyText` flag is false, we'll return the matches
if ( ! getOnlyText ) {
return qualifyingDocs ;
}
let docs = matches
? qualifyingDocs . map ( ( match ) => ( match . metadata as Metadata ) . chunk )
: [ ] ;
// Join all the chunks of text together, truncate to the maximum number of tokens, and return the result
return docs . join ( "n" ) . substring ( 0 , maxTokens ) ;
} ;
مرة أخرى في chat/route.ts
، سنضيف المكالمة إلى getContext
:
const { messages } = await req . json ( ) ;
// Get the last message
const lastMessage = messages [ messages . length - 1 ] ;
// Get the context from the last message
const context = await getContext ( lastMessage . content , "" ) ;
أخيرًا ، سنقوم بتحديث المطالبة لتضمين السياق الذي استعدناه من وظيفة getContext
.
const prompt = [
{
role : "system" ,
content : `AI assistant is a brand new, powerful, human-like artificial intelligence.
The traits of AI include expert knowledge, helpfulness, cleverness, and articulateness.
AI is a well-behaved and well-mannered individual.
AI is always friendly, kind, and inspiring, and he is eager to provide vivid and thoughtful responses to the user.
AI has the sum of all knowledge in their brain, and is able to accurately answer nearly any question about any topic in conversation.
AI assistant is a big fan of Pinecone and Vercel.
START CONTEXT BLOCK
${ context }
END OF CONTEXT BLOCK
AI assistant will take into account any CONTEXT BLOCK that is provided in a conversation.
If the context does not provide the answer to question, the AI assistant will say, "I'm sorry, but I don't know the answer to that question".
AI assistant will not apologize for previous responses, but instead will indicated new information was gained.
AI assistant will not invent anything that is not drawn directly from the context.
` ,
} ,
] ;
في هذه المطالبة ، أضفنا START CONTEXT BLOCK
END OF CONTEXT BLOCK
للإشارة إلى مكان إدراج السياق. أضفنا أيضًا سطرًا للإشارة إلى أن مساعد AI سيأخذ في الاعتبار أي كتلة سياق يتم توفيرها في محادثة.
بعد ذلك ، نحتاج إلى إضافة لوحة السياق إلى واجهة المستخدم للدردشة. سنضيف مكونًا جديدًا يسمى Context
(رمز كامل).
نريد السماح للواجهة بالإشارة إلى أجزاء من المحتوى الذي تم استرداده تم استخدامه لإنشاء الاستجابة. للقيام بذلك ، سنضيف نقطة نهاية أخرى ستتصل بنفس getContext
.
export async function POST ( req : Request ) {
try {
const { messages } = await req . json ( ) ;
const lastMessage =
messages . length > 1 ? messages [ messages . length - 1 ] : messages [ 0 ] ;
const context = ( await getContext (
lastMessage . content ,
"" ,
10000 ,
0.7 ,
false
) ) as ScoredPineconeRecord [ ] ;
return NextResponse . json ( { context } ) ;
} catch ( e ) {
console . log ( e ) ;
return NextResponse . error ( ) ;
}
}
عندما يزحف المستخدم عنوان URL ، ستعرض لوحة السياق جميع شرائح صفحة الويب التي تم استردادها. كلما أكملت الواجهة الخلفية إرسال رسالة إلى الوراء ، ستؤدي الواجهة الأمامية إلى إثارة تأثير يسترجع هذا السياق:
useEffect ( ( ) => {
const getContext = async ( ) => {
const response = await fetch ( "/api/context" , {
method : "POST" ,
body : JSON . stringify ( {
messages ,
} ) ,
} ) ;
const { context } = await response . json ( ) ;
setContext ( context . map ( ( c : any ) => c . id ) ) ;
} ;
if ( gotMessages && messages . length >= prevMessagesLengthRef . current ) {
getContext ( ) ;
}
prevMessagesLengthRef . current = messages . length ;
} , [ messages , gotMessages ] ) ;
يستخدم Pinecone-Vercel-Starter الكاتب المسرحي لإنهاء الاختبار.
لتشغيل جميع الاختبارات:
npm run test:e2e
بشكل افتراضي ، عند التشغيل محليًا ، إذا تمت مواجهة الأخطاء ، سيفتح الكاتب المسرحي تقرير HTML الذي يوضح الاختبارات التي فشلت ولأي برامج تشغيل المتصفح.
لعرض أحدث تقرير اختبار محليًا ، قم بتشغيل:
npm run test:show