بناء شات بوت ذكاء اصطناعي مجاني لموقعك عبر Cloudflare Workers

بناء شات بوت ذكاء اصطناعي مجاني لموقعك عبر Cloudflare Workers

بناء شات بوت ذكاء اصطناعي مجاني لموقعك باستخدام Cloudflare Workers

بدون فاتورة شهرية لـ Intercom، وبدون سقف رسائل مزعج — ابنِ شات بوت الدعم الخاص بك على الخطة المجانية من Cloudflare.

إذا سبق لك أن بحثت عن سعر شات بوت ذكاء اصطناعي لموقعك، فأنت تعرف الصدمة. Intercom Fin AI يتقاضى نحو دولار واحد عن كل محادثة يتم حلّها. أما Chatbase وTidio وأغلب الأدوات "بدون كود" فتمنحك خطة مجانية محدودة بضع مئات من الرسائل شهريًا فقط، ثم تدفعك بهدوء نحو اشتراك يتراوح بين 20 و100 دولار وأكثر شهريًا. لموقع صغير، أو مشروع SaaS ناشئ، أو موقع عميل لا يحصل على زيارات ضخمة، هذا الحساب غالبًا لا يستحق العناء.

في هذا الدليل سأريك كيف تبني شات بوت ذكاء اصطناعي مجاني بالكامل يمكنك تضمينه في أي موقع بسطر برمجي واحد — شات بوت يبثّ الردود لحظيًا، يجيب على أسئلة زوارك من ملف الأسئلة الشائعة الخاص بك عبر تقنية RAG (اختصار لـ Retrieval Augmented Generation، ويعني أن النموذج يبحث أولًا عن معلومات دقيقة قبل أن يجيب بدلًا من التخمين)، ويتذكّر محادثة الزائر حتى بعد إعادة تحميل الصفحة. سنبني كل هذا بالكامل على الخطة المجانية من Cloudflare: خدمة Workers للخلفية البرمجية، وWorkers AI لتشغيل النموذج، وVectorize للبحث الدلالي، وKV لتخزين الجلسات.

جرّبتُ هذه الحزمة بالضبط على صفحة دعم منخفضة الزيارات، وحدود الخطة المجانية (100,000 طلب يوميًا لـ Workers، و10,000 "نيورون" ذكاء اصطناعي يوميًا) كانت بعيدة جدًا عن أن تشكّل مشكلة. إذا كنت تجيد كتابة أوامر الطرفية (Terminal)، يمكنك تشغيل هذا المشروع فعليًا خلال أقل من ساعة — بلا بطاقة ائتمان، وبلا فاتورة شهرية.

أنا مصطفى أمان، وفي مدونة وادي التكنولوجيا أكتب أدلة تقنية عملية لمن يفضّل بناء الأداة بنفسه على دفع اشتراك شهري مقابلها. لنبدأ.

ما الذي تحتاجه قبل البدء

هذا الدليل يفترض أنك تجيد أوامر الطرفية الأساسية، ولديك فكرة عامة عن كيفية عمل الـ APIs، لكنك لا تحتاج أي خبرة سابقة مع Cloudflare تحديدًا. إليك القائمة الكاملة:

  • حساب Cloudflare مجاني — سجّل عبر dash.cloudflare.com. لا حاجة لبطاقة ائتمان للخطة المجانية.
  • Node.js إصدار 18 فأحدث — نزّله من nodejs.org، وتحقّق منه بأمر node --version.
  • إلمام بسيط بـ JavaScript — يكفي أن تقرأ الكود وتلصقه. لن تكتب منطقًا معقدًا من الصفر.
  • طرفية (Terminal) أو موجّه أوامر — على Windows استخدم CMD أو PowerShell، وعلى macOS/Linux أي Shell يعمل.
  • محتوى الأسئلة الشائعة جاهزًا — قائمة قصيرة من 3 إلى 10 أزواج سؤال وجواب عن منتجك أو خدمتك.
  • ساعة واحدة من التركيز — العمل الفعلي يستغرق نحو 30 دقيقة إعدادًا و15 دقيقة اختبارًا، لكن الساعة الكاملة تمنحك مساحة لتصحيح أي مفاجأة.
💡 جديد على Cloudflare؟ دليل Workers Quickstart الرسمي وتوثيق Vectorize مرجعان ممتازان إذا أردت التعمّق بعد هذا الشرح.

لماذا تبني شات بوتك الخاص بدل الاشتراك في Intercom أو Chatbase؟

لستُ ضد أدوات SaaS الجاهزة — إذا كان لديك فريق دعم من عشرة موظفين، فمنصة مثل Intercom تستحق سعرها فعلًا بما توفّره من تذاكر (Ticketing) وتحويل مباشر لموظف بشري وتحليلات جاهزة. لكن أغلب أصحاب المواقع يريدون فقط أن يحصل زوّارهم على إجابات سريعة ودقيقة للأسئلة المتكررة، وهنا تدفع مقابل آلية ضخمة لن تلمسها أبدًا. هذه مقارنة واقعية بين المسارين:

الخيار التكلفة التقريبية جهد الإعداد تملك البيانات
Intercom Fin AI ~0.99$ لكل محادثة مُحلّة + رسوم مقاعد منخفض (إعداد من لوحة التحكم) لا
Chatbase / Tidio (الخطة المجانية) مجاني حتى سقف رسائل منخفض، ثم 20–100$+ شهريًا منخفض جدًا جزئيًا
Cloudflare Workers (هذا الدليل) 0$ لمعظم المواقع متوسطة الزيارات متوسط (بناء لمرة واحدة) نعم بالكامل

المقايضة صريحة: تدفع ساعة من وقت الإعداد مقابل تملّك كامل وتكلفة متكررة تساوي صفرًا. ولاحظ أن أغلب ما تجده في المحتوى العربي عن "شات بوت مجاني للموقع" يتحدث عن أدوات بدون كود مثل Chatfuel أو Zoho SalesIQ، وهي مفيدة فعلًا، لكنها تبقيك رهين حدود الخطة المجانية وواجهة الشركة. هذا الدليل مختلف: نبني بنية تحتية خاصة بك بالكامل.

ما الذي ستبنيه: البنية المعمارية ببساطة

قبل أن تلمس الطرفية، من المفيد أن ترى القطع المتحركة. المشروع بالكامل عبارة عن ملفين يتحدثان مع أربع خدمات من Cloudflare:

  • Worker خلفي (Backend) — دالة Serverless صغيرة تستقبل رسائل الدردشة، تبحث عن سياق مناسب من الأسئلة الشائعة، تستدعي نموذج الذكاء الاصطناعي، وتبثّ الرد.
  • سكربت واجهة قابل للتضمين (Widget) — ملف JavaScript واحد قابل للتضمين يرسم فقاعة الدردشة ونافذتها في أي صفحة تُحمّله.
  • Workers AI — يشغّل نموذج اللغة الفعلي (سنستخدم Llama 3 من Meta) على حافة الشبكة (Edge)، فلا تدفع لـ OpenAI عن كل رمز (Token).
  • Vectorize — قاعدة بيانات متجهات (Vector Database). تخزّن أسئلتك الشائعة كتمثيلات رقمية (Embeddings) ليجد البوت أنسب إجابة حسب المعنى وليس فقط تطابق الكلمات الحرفي. هذا هو جوهر تقنية RAG.
  • KV (مخزن مفتاح-قيمة) — تخزين موزّع عالميًا من Cloudflare، نستخدمه هنا لتذكّر محادثة كل زائر بين تحميلات الصفحة.

لا شيء من هذا يعمل على خادم تديره أنت. شبكة Cloudflare الطرفية (أكثر من 300 موقع حول العالم) تشغّل كودك قريبًا من أي زائر لموقعك، وهذا أيضًا سبب بقاء زمن الاستجابة سريعًا حتى على الخطة المجانية.

الخطوة 1: إنشاء المشروع وموارد Cloudflare

ستحتاج حساب Cloudflare مجانيًا و Node.js 18 أو أحدث مثبّتًا. ابدأ بإنشاء هيكل مشروع Worker:

Terminal

npm create cloudflare@latest support-widget
cd support-widget
npm install --save-dev tailwindcss wrangler

اختر javascript عند السؤال، واختر "لا" عندما يُطلب منك النشر فورًا — سننشر عندما يكون كل شيء جاهزًا. بعدها ثبّت Wrangler (أداة سطر أوامر Cloudflare) بشكل عام وسجّل الدخول:

Terminal

npm install -g wrangler
wrangler login

الآن أنشئ الموردين اللذين يعتمد عليهما المشروع — فهرس Vectorize لأسئلتك الشائعة، ومساحة KV لجلسات الدردشة:

Terminal

npx wrangler vectorize create support-faq --dimensions=768 --metric=cosine
npx wrangler kv namespace create CHAT_HISTORY

احتفظ بالمعرّف (ID) الذي يطبعه الأمر الثاني، فستحتاجه في ملف الإعدادات بالخطوة التالية.

الخطوة 2: ربط كل شيء في wrangler.jsonc

ملف الإعدادات هذا يخبر الـ Worker بالخدمات المسموح له استخدامها. أنشئ wrangler.jsonc في جذر مشروعك:

wrangler.jsonc

{
  "name": "support-widget",
  "main": "src/worker.js",
  "compatibility_date": "2026-01-01",
  "assets": { "directory": "./public", "binding": "ASSETS" },
  "ai": { "binding": "AI" },
  "vectorize": [
    { "binding": "FAQ_INDEX", "index_name": "support-faq" }
  ],
  "kv_namespaces": [
    { "binding": "CHAT_HISTORY", "id": "ضع_معرّف_KV_هنا" }
  ]
}
💡 ماذا تفعل كل خدمة (Binding)؟ ASSETS يخدّم ملفات الواجهة الثابتة، AI يمنح الـ Worker صلاحية تشغيل النماذج، FAQ_INDEX يربطه بقاعدة البيانات المتجهية، و CHAT_HISTORY هو مكان تخزين محادثات الزوّار.

الخطوة 3: بناء Worker الخلفي

هنا يعيش المنطق الفعلي — استقبال رسالة، سحب سياق الأسئلة الشائعة المناسب، استدعاء النموذج، وبثّ الإجابة مع حفظها في KV. أنشئ src/worker.js:

src/worker.js

const SYSTEM_PROMPT = "أنت مساعد دعم فني مختصر وودود. استخدم سياق الأسئلة الشائعة المرفق إن وُجد. إن لم تكن متأكدًا، قل ذلك بدل التخمين.";
const SESSION_TTL = 60 * 60 * 24 * 14; // 14 يومًا

// نطاق الـ widget يختلف غالبًا عن نطاق الموقع المُضمَّن فيه، لذا نعكس
// الـ Origin الحقيقي للطلب بدل استخدام علامة النجمة (*) — فالنجمة لا يمكن
// دمجها مع طلبات تحمل بيانات اعتماد (كوكيز).
function corsHeaders(request) {
  return {
    "Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
    "Access-Control-Allow-Credentials": "true",
    "Vary": "Origin",
    "X-Content-Type-Options": "nosniff",
  };
}

function getSessionId(request) {
  const match = request.headers.get("Cookie")?.match(/widget_sid=([^;]+)/);
  return match ? match[1] : null;
}

async function findRelevantFaq(env, question) {
  const embedded = await env.AI.run("@cf/baai/bge-base-en-v1.5", { text: [question] });
  if (!embedded?.data?.length) return "";
  const matches = await env.FAQ_INDEX.query(embedded.data[0], { topK: 3, returnMetadata: true });
  return matches.matches
    .filter((m) => m.score > 0.6) // نتجاهل التطابقات الضعيفة بدل إرباك النموذج بضجيج
    .map((m) => `Q: ${m.metadata?.question}\nA: ${m.metadata?.answer}`)
    .join("\n\n");
}

// خدمة Workers AI تبثّ أحداث SSE، لكن حدود الأجزاء (Chunks) لا تحترم حدود الأسطر —
// سطر "data: {...}" واحد قد يصل مقسّمًا على جزأين. التخزين المؤقت وتحليل الأسطر
// المكتملة فقط يمنع فقدان أجزاء من النص بصمت.
function createSseLineBuffer(onEvent) {
  let buffer = "";
  return {
    push(text) {
      buffer += text;
      const lines = buffer.split("\n");
      buffer = lines.pop();
      for (const line of lines) {
        if (line.startsWith("data: ") && !line.includes("[DONE]")) {
          try { onEvent(JSON.parse(line.slice(6))); } catch (_) {}
        }
      }
    },
  };
}

async function handleChat(request, env) {
  const cors = corsHeaders(request);
  const { message } = await request.json();
  if (!message?.trim()) {
    return new Response(JSON.stringify({ error: "الرسالة مطلوبة" }), { status: 400, headers: { "Content-Type": "application/json", ...cors } });
  }

  let sessionId = getSessionId(request);
  let session = sessionId ? await env.CHAT_HISTORY.get(sessionId, "json") : null;

  // needsCookie يجب أن يتتبّع ما إذا أنشأنا جلسة جديدة فعلًا — وليس فقط
  // ما إذا كان هناك كوكي موجود. فإذا بقي الكوكي لكن انتهت صلاحية سجل KV،
  // فإن الكود القديم كان سيبدأ جلسة جديدة بصمت دون إعادة إصدار الكوكي،
  // مما يكسر الذاكرة نهائيًا.
  let needsCookie = false;
  if (!session) {
    sessionId = crypto.randomUUID();
    session = { history: [] };
    needsCookie = true;
  }

  session.history.push({ role: "user", content: message.trim() });
  const faqContext = await findRelevantFaq(env, message);
  const promptMessages = [
    { role: "system", content: SYSTEM_PROMPT + (faqContext ? `\n\nسياق الأسئلة الشائعة:\n${faqContext}` : "") },
    ...session.history.slice(-8),
  ];

  const modelStream = await env.AI.run("@cf/meta/llama-3-8b-instruct", { messages: promptMessages, stream: true });
  let fullReply = "";
  const decoder = new TextDecoder();
  const lineBuffer = createSseLineBuffer((chunk) => { fullReply += chunk.response || ""; });

  const { readable, writable } = new TransformStream({
    transform(chunk, controller) {
      lineBuffer.push(decoder.decode(chunk, { stream: true }));
      controller.enqueue(chunk);
    },
    async flush() {
      if (fullReply) {
        session.history.push({ role: "assistant", content: fullReply });
        await env.CHAT_HISTORY.put(sessionId, JSON.stringify(session), { expirationTtl: SESSION_TTL });
      }
    },
  });

  modelStream.pipeTo(writable).catch((err) => console.error("خطأ بث:", err));

  const headers = { "Content-Type": "text/event-stream", ...cors };
  // SameSite=None + Secure مطلوب (وليس Lax) لأن هذا طلب Cross-Site من نطاق
  // الموقع المُضيف إلى نطاق الـ Worker. كوكيز Lax تُحجب في طلبات fetch
  // العابرة للنطاقات، وهذا يكسر الذاكرة بصمت.
  if (needsCookie) headers["Set-Cookie"] = `widget_sid=${sessionId}; Path=/; HttpOnly; SameSite=None; Secure; Max-Age=${SESSION_TTL}`;
  return new Response(readable, { headers });
}

async function handleHistory(request, env) {
  const cors = corsHeaders(request);
  const sessionId = getSessionId(request);
  const session = sessionId ? await env.CHAT_HISTORY.get(sessionId, "json") : null;
  return new Response(JSON.stringify({ history: session?.history || [] }), { headers: { "Content-Type": "application/json", ...cors } });
}

async function handleSeed(request, env) {
  const cors = corsHeaders(request);
  const providedKey = request.headers.get("X-Seed-Key");
  if (!env.SEED_KEY || providedKey !== env.SEED_KEY) {
    return new Response(JSON.stringify({ error: "غير مصرَّح" }), { status: 401, headers: { "Content-Type": "application/json", ...cors } });
  }

  const { faq } = await request.json(); // faq: [{ question, answer }, ...]
  if (!Array.isArray(faq) || !faq.length) {
    return new Response(JSON.stringify({ error: "أرسل مصفوفة faq غير فارغة" }), { status: 400, headers: { "Content-Type": "application/json", ...cors } });
  }

  const vectors = [];
  for (const entry of faq) {
    const embedded = await env.AI.run("@cf/baai/bge-base-en-v1.5", { text: [entry.question] });
    vectors.push({
      id: crypto.randomUUID(),
      values: embedded.data[0],
      metadata: { question: entry.question, answer: entry.answer },
    });
  }

  await env.FAQ_INDEX.upsert(vectors);
  return new Response(JSON.stringify({ seeded: vectors.length }), { headers: { "Content-Type": "application/json", ...cors } });
}

export default {
  async fetch(request, env) {
    const path = new URL(request.url).pathname;
    const cors = corsHeaders(request);

    if (request.method === "OPTIONS") {
      return new Response(null, { headers: { ...cors, "Access-Control-Allow-Methods": "GET,POST,OPTIONS", "Access-Control-Allow-Headers": "Content-Type, X-Seed-Key" } });
    }
    if (path === "/api/chat" && request.method === "POST") return handleChat(request, env);
    if (path === "/api/history") return handleHistory(request, env);
    if (path === "/api/seed" && request.method === "POST") return handleSeed(request, env);
    return env.ASSETS.fetch(request);
  },
};
⚠️ الخطأ الذي يكسر الذاكرة بصمت: استخدام علامة النجمة Access-Control-Allow-Origin: * مع طلب يحمل بيانات اعتماد (كوكيز) يُرفض من كل متصفح حديث — وبما أن نطاق الـ widget نادرًا ما يطابق نطاق الموقع المُضمَّن فيه، فهذا طلب Cross-Site بطبيعته. أضف إلى ذلك أن كوكي SameSite=Lax يُحجب تمامًا في طلبات fetch العابرة للنطاقات. أخطئ في أي من الاثنين وسيبدو الشات بوت يعمل في أول رسالة، ثم ينسى كل شيء بهدوء في الرسالة التالية — بلا أي خطأ ظاهر. لهذا السبب يعكس الكود أعلاه الـ Origin الحقيقي ويستخدم SameSite=None; Secure.

بضعة تفاصيل أخرى تستحق التنويه. كوكي الجلسة HttpOnly، ما يمنع سكربتات جانب العميل من قراءته — عادة أمنية صغيرة لكنها مهمة لأي شيء يتتبّع زائرًا عبر عدة طلبات. كما أن البحث عن الأسئلة الشائعة يستبعد التطابقات الضعيفة تحت درجة تشابه 0.6، فسؤال غير مرتبط تمامًا لا يُغذَّى بـ "سياق" مضلِّل يربك النموذج. ومحلّل SSE يخزّن الأسطر الجزئية مؤقتًا بدل تحليل كل جزء بمعزل عن غيره — بدون هذا التخزين، قد تفقد الردود المُبثَّة كلمات عشوائية كلما انقسم سطر JSON بين حزمتَي شبكة، وهذا النوع من الأخطاء يظهر متقطعًا ويصعب تتبّعه لاحقًا.

وأخيرًا، لاحظ أن needsCookie يُضبط عند إنشاء جلسة جديدة — وليس فقط عند غياب الكوكي في الطلب الوارد. إذا بقي كوكي الزائر لكن انتهت صلاحية سجل KV الخاص به (بعد فترة الـ 14 يومًا)، فإن النهج الأصلي كان سيبدأ جلسة جديدة بصمت دون إعادة إصدار الكوكي أبدًا، تاركًا سجل ذلك الزائر غير قابل للاسترجاع في كل زيارة مستقبلية. هذا النوع من الحالات الحدّية يجتاز اختبارًا يدويًا سريعًا، ثم ينكسر بعد أسبوعين من التشغيل الفعلي.

الخطوة 4: إعداد Tailwind CSS للواجهة

بما أن هذا الـ widget سيُضمَّن في موقع شخص آخر، فاستخدام إطار CSS كامل بأصناف نطاقها محدودة يمنع أي تعارض مع تنسيقات الموقع المُضيف الحالية. أنشئ tailwind.config.js:

tailwind.config.js

module.exports = {
  content: ["./public/**/*.{html,js}"],
  darkMode: "class",
  theme: { extend: {} },
};

ثم أضف ملف تنسيق مصدري في src/styles.css بالتوجيهات الثلاثة القياسية لـ Tailwind:

src/styles.css

@tailwind base;
@tailwind components;
@tailwind utilities;

وأضف هذه السكربتات إلى package.json:

package.json (قسم scripts)

"scripts": {
  "build:css": "npx tailwindcss -i ./src/styles.css -o ./public/widget.css --minify",
  "dev": "npm run build:css && wrangler dev",
  "deploy": "npm run build:css && wrangler deploy"
}

الخطوة 5: بناء سكربت الواجهة القابل للتضمين

هذا هو الملف الذي سيُحمّله أي موقع بوسم <script> واحد. يرسم فقاعة الدردشة، يتعامل مع فتح وإغلاق النافذة، ويبثّ رد الذكاء الاصطناعي في الصفحة. أنشئ public/widget.js:

public/widget.js

(function () {
  const config = {
    baseUrl: window.SUPPORT_WIDGET_URL || "",
    title: window.SUPPORT_WIDGET_TITLE || "الدعم الفني",
    greeting: window.SUPPORT_WIDGET_GREETING || "أهلًا! كيف يمكنني مساعدتك؟",
  };
  let messages = [];
  let isOpen = false;
  let isSending = false;

  // محتوى الرسائل يأتي من الزائر ومن النموذج معًا — كلاهما غير موثوق.
  // بدون الإفلات (Escaping)، كتابة "<img src=x onerror=alert(1)>" في
  // مربع الدردشة سيُنفَّذ كسكربت حقيقي على الموقع المُضيف.
  function escapeHtml(str) {
    return str
      .replace(/&/g, "&")
      .replace(/</g, "<")
      .replace(/>/g, ">")
      .replace(/"/g, """)
      .replace(/'/g, "'");
  }

  function injectMarkup() {
    const styleLink = document.createElement("link");
    styleLink.rel = "stylesheet";
    styleLink.href = config.baseUrl + "/widget.css";
    document.head.appendChild(styleLink);

    const root = document.createElement("div");
    root.id = "support-widget-root";
    root.innerHTML = `
      <button id="sw-toggle" class="fixed bottom-6 right-6 w-14 h-14 bg-emerald-600 rounded-full shadow-xl z-[99999] text-2xl">💬</button>
      <div id="sw-panel" class="fixed bottom-24 right-6 w-96 max-w-[calc(100vw-2rem)] h-[560px] max-h-[75vh] rounded-2xl shadow-2xl bg-white hidden flex-col z-[99999]">
        <div class="flex items-center justify-between px-4 py-3 border-b font-semibold">
          <span>${escapeHtml(config.title)}</span>
          <button id="sw-close" class="text-gray-400 hover:text-gray-700 text-lg leading-none">✕</button>
        </div>
        <div id="sw-messages" class="flex-1 overflow-y-auto p-4 space-y-3"></div>
        <form id="sw-form" class="flex gap-2 p-3 border-t">
          <input id="sw-input" class="flex-1 border rounded-full px-4 py-2 text-sm" placeholder="اكتب رسالة..." autocomplete="off" />
          <button type="submit" class="px-4 bg-emerald-600 text-white rounded-full text-sm">إرسال</button>
        </form>
      </div>`;
    document.body.appendChild(root);
  }

  function renderMessages(showTyping) {
    const container = document.getElementById("sw-messages");
    const bubbles = messages
      .map((m) => `<div class="text-sm ${m.role === "user" ? "text-right" : "text-left"}">
          <span class="inline-block px-3 py-2 rounded-xl ${m.role === "user" ? "bg-emerald-600 text-white" : "bg-gray-100"}">${escapeHtml(m.content)}</span>
        </div>`)
      .join("");
    const typingBubble = showTyping
      ? `<div class="text-left"><span class="inline-block px-3 py-2 rounded-xl bg-gray-100 text-sm text-gray-400">يكتب…</span></div>`
      : "";
    container.innerHTML = bubbles + typingBubble;
    container.scrollTop = container.scrollHeight;
  }

  // إصلاح تخزين مؤقت مماثل لما في الخلفية — حد الجزء قد يقسّم سطر
  // "data: {...}" واحدًا إلى قسمين، لذا نحتفظ بأي سطر ناقص متأخر.
  function createSseLineBuffer(onEvent) {
    let buffer = "";
    return {
      push(text) {
        buffer += text;
        const lines = buffer.split("\n");
        buffer = lines.pop();
        for (const line of lines) {
          if (line.startsWith("data: ") && !line.includes("[DONE]")) {
            try { onEvent(JSON.parse(line.slice(6))); } catch (_) {}
          }
        }
      },
    };
  }

  async function sendMessage(text) {
    if (isSending) return; // يمنع طلبات متداخلة إذا ضغط أحدهم Enter بسرعة
    isSending = true;
    messages.push({ role: "user", content: text });
    renderMessages(true);

    try {
      const response = await fetch(config.baseUrl + "/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: text }),
        credentials: "include",
      });
      if (!response.ok) throw new Error("فشل الطلب: " + response.status);

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let reply = "";
      let index = null;

      const lineBuffer = createSseLineBuffer((chunk) => {
        if (!chunk.response) return;
        reply += chunk.response;
        if (index === null) {
          messages.push({ role: "assistant", content: reply });
          index = messages.length - 1;
        } else {
          messages[index].content = reply;
        }
        renderMessages(false);
      });

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        lineBuffer.push(decoder.decode(value, { stream: true }));
      }

      if (index === null) {
        messages.push({ role: "assistant", content: "عذرًا، لم أفهم ذلك — هل يمكنك المحاولة مجددًا؟" });
        renderMessages(false);
      }
    } catch (err) {
      messages.push({ role: "assistant", content: "حدث خطأ في الوصول للدعم. حاول مرة أخرى بعد قليل." });
      renderMessages(false);
    } finally {
      isSending = false;
    }
  }

  function togglePanel(forceOpen) {
    isOpen = typeof forceOpen === "boolean" ? forceOpen : !isOpen;
    const panel = document.getElementById("sw-panel");
    panel.classList.toggle("hidden", !isOpen);
    panel.classList.toggle("flex", isOpen);
    if (isOpen && !messages.length) {
      messages.push({ role: "assistant", content: config.greeting });
      renderMessages(false);
    }
  }

  function bindEvents() {
    document.getElementById("sw-toggle").onclick = () => togglePanel();
    document.getElementById("sw-close").onclick = () => togglePanel(false);
    document.getElementById("sw-form").onsubmit = (e) => {
      e.preventDefault();
      const input = document.getElementById("sw-input");
      const text = input.value.trim();
      if (!text) return;
      input.value = "";
      sendMessage(text);
    };
  }

  async function loadHistory() {
    try {
      const res = await fetch(config.baseUrl + "/api/history", { credentials: "include" });
      if (res.ok) {
        const data = await res.json();
        if (data.history?.length) {
          messages = data.history;
        }
      }
    } catch (_) {}
  }

  async function init() {
    injectMarkup();
    await loadHistory();
    bindEvents();
  }

  document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", init) : init();
})();
⚠️ لا تتخطَّ أبدًا إفلات محتوى الدردشة (HTML-escaping): كل من رسالة الزائر ورد النموذج تُدرَجان في الصفحة عبر innerHTML. لو أدرجت هذا النص كما هو دون إفلات، فإن زائرًا يكتب وسمًا مثل <img onerror=...> في مربع الدردشة سيُنفَّذ كـ HTML حقيقي على أي موقع مُضمَّن فيه الـ widget — وهذه ثغرة XSS مخزّنة. دالة escapeHtml() أعلاه ليست تحسينًا اختياريًا؛ تخطَّها وستكون قد شحنت ثغرة أمنية في كل موقع يثبّت الـ widget الخاص بك.

كل الكود ملفوف داخل دالة تُنفَّذ فورًا (IIFE) لا تُسرّب متغيرات إلى النطاق العام للموقع المُضيف — عادة مهمة لأي سكربت مصمَّم للتضمين في موقع آخر. حلقة البث في sendMessage() تحدّث فقاعة الرسالة نفسها في مكانها مع وصول كل رمز، بدل إعادة رسم نافذة الدردشة كاملة عند كل جزء — وهذا ما ينتج تأثير "الكتابة" السلس بدل الوميض المزعج. و try/catch حول طلب fetch يعني أن انقطاع الاتصال يُظهر للزائر رسالة احتياطية ودّية بدل نافذة دردشة معلَّقة بلا أي رد فعل — وهو الشيء الذي تتجاهله عادة النسخة المبسّطة من هذا النمط.

الخطوة 6: اختبار محلي، ثم النشر

ملف واحد لا يزال ناقصًا: بما أن env.ASSETS.fetch() يخدّم كل ما في ./public، فإن زيارة الرابط الجذري الآن ستعطي خطأ 404 — لا توجد صفحة هناك بعد. أضف صفحة تجريبية بسيطة حتى يكون لدى wrangler dev ما يعرضه فعليًا. أنشئ public/index.html:

public/index.html

<!doctype html>
<html lang="ar" dir="rtl">
<head>
  <meta charset="utf-8" />
  <title>شات بوت الدعم — تجربة محلية</title>
</head>
<body style="font-family: sans-serif; padding: 40px; max-width: 640px; margin: 0 auto;">
  <h1>صفحة تجربة محلية</h1>
  <p>هذه الصفحة موجودة فقط ليعرض عليها خادم التطوير شيئًا في الجذر.
    فقاعة الدردشة يجب أن تظهر في الزاوية السفلية — اضغط عليها للتجربة.</p>
  <script>
    window.SUPPORT_WIDGET_URL = ""; // نفس النطاق أثناء الاختبار المحلي
    window.SUPPORT_WIDGET_TITLE = "اسألنا أي شيء";
  </script>
  <script src="/widget.js"></script>
</body>
</html>

شغّل خادم التطوير وافتحه في متصفحك:

Terminal

npm run dev
# يفتح على http://localhost:8787

اضغط فقاعة الدردشة في الزاوية وأرسل رسالة تجريبية — يجب أن ترى الرد يُبثّ رمزًا بعد رمز. إذا لم يحدث شيء، افتح وحدة تحكم المتصفح (Console) أولًا؛ سيظهر أي خطأ CORS أو كوكيز فورًا هناك.

⚠️ ملاحظة اختبار محلي — الكوكيز لا تعمل على HTTP العادي: الكوكي يُضبط بخاصية SameSite=None; Secure، وهي مطلوبة للطلبات العابرة للنطاقات في الإنتاج. لكن المتصفحات الحديثة ترفض خاصية Secure على اتصالات http://localhost العادية. هذا يعني أن كوكي الجلسة لن يُضبط أثناء الاختبار المحلي عبر wrangler dev، وسيبدو الشات بوت وكأنه ينسى المحادثة بعد كل رسالة. هذا أمر متوقّع — يعمل الكوكي بشكل صحيح فور النشر على نقطة نهاية HTTPS من Cloudflare. للاختبار محليًا مع استمرارية جلسة كاملة، استخدم wrangler dev --remote (يستخدم حسابك الفعلي في Cloudflare عبر HTTPS) أو مرِّر الاتصال عبر أداة مثل ngrok.

بعد أن تكون راضيًا عن النتيجة محليًا، انشر بأمر واحد:

Terminal

npm run deploy

ستحصل على رابط حي يشبه support-widget.YOUR-SUBDOMAIN.workers.dev. لكن البوت لن يستطيع الإجابة عن الأسئلة الشائعة بعد — فهرس Vectorize لا يزال فارغًا. هذا ما تحلّه الخطوة التالية.

تغذية بيانات الأسئلة الشائعة في Vectorize

قبل أن يستطيع الشات بوت الإجابة عن أسئلة حقيقية، تحتاج إلى تحويل أسئلتك الشائعة إلى تمثيلات رقمية (Embeddings) وتخزينها في الفهرس المتجهي. دالة handleSeed() التي أضفناها بالفعل إلى worker.js (في كتلة الكود بالخطوة 3، تحت مسار /api/seed) تتكفّل بهذا — تستقبل قائمة من أزواج سؤال/جواب، تحوّل كل سؤال إلى Embedding، وتُدرجها في Vectorize. وهي محمية بمفتاح سري حتى لا يستطيع أي غريب الكتابة فوق أسئلتك الشائعة.

اضبط ذلك المفتاح السري قبل النشر:

Terminal

npx wrangler secret put SEED_KEY
# ألصق أي نص عشوائي عند السؤال — ستستخدمه لاحقًا

ثم استدعِ نقطة النهاية مرة واحدة بمحتوى أسئلتك الشائعة:

Terminal

curl -X POST https://support-widget.YOUR-SUBDOMAIN.workers.dev/api/seed \
  -H "Content-Type: application/json" \
  -H "X-Seed-Key: YOUR_SECRET_HERE" \
  -d '{
    "faq": [
      { "question": "ما مدة الشحن؟", "answer": "تُشحن الطلبات خلال يومي عمل وتصل خلال 3 إلى 7 أيام حسب الموقع." },
      { "question": "هل تتوفر إمكانية الاسترجاع؟", "answer": "نعم، استرجاع كامل خلال 30 يومًا من الشراء." },
      { "question": "كيف أتواصل مع الدعم؟", "answer": "راسلنا على [email protected] أو استخدم هذا الشات بوت." }
    ]
  }'

استجابة ناجحة تُرجع {"seeded": 3} — تأكيدًا بأن ثلاثة أسئلة شائعة تم تحويلها إلى Embeddings وتخزينها. أعد تشغيل نفس الاستدعاء متى تغيّر محتوى أسئلتك الشائعة؛ لا حاجة لمسح الفهرس أولًا لأن كل إدخال يحصل على معرّف فريد جديد.

الخطوة 7: تضمين الشات بوت في أي موقع

هذا هو بيت القصيد — إضافة الشات بوت إلى أي موقع، حتى لو بُني بتقنية مختلفة تمامًا، يستغرق سطرين قبل وسم الإغلاق </body>:

HTML

<script>
  window.SUPPORT_WIDGET_URL = "https://support-widget.YOUR-SUBDOMAIN.workers.dev";
  window.SUPPORT_WIDGET_TITLE = "اسألنا أي شيء";
</script>
<script src="https://support-widget.YOUR-SUBDOMAIN.workers.dev/widget.js"></script>

هذا كل شيء. بما أن الـ widget سكربت مستقل بأنماطه الخاصة، فلن يتعارض مع CSS الموجود في موقعك — سيعمل بالطريقة نفسها سواء كان الموقع المُضيف مبنيًا بـ WordPress، أو مولّد مواقع ثابتة، أو قالب Shopify مخصّص.

5 أخطاء أراها كثيرًا في شات بوتات مستضافة ذاتيًا

  1. تجاهل سياق الأسئلة الشائعة تمامًا. بدون RAG، أنت فقط تشغّل نموذج دردشة عام لا يعرف شيئًا عن منتجك. سيهلوس بثقة. حتى مجموعة صغيرة من أزواج سؤال وجواب مكتوبة بعناية تُحدث فرقًا ملحوظًا في الدقة.
  2. عدم تحديد سقف لتاريخ المحادثة. إذا أرسلت كامل تاريخ الدردشة للنموذج في كل رسالة، فإن زمن الاستجابة واستهلاك الـ neurons يرتفعان بسرعة. اقتصر على آخر 6-10 رسائل، وهذا كافٍ تمامًا لمحادثات دعم فني.
  3. استخدام نطاق CORS عام مع الكوكيز. الـ widget مستضاف على نطاق مختلف عن الصفحة المُضمَّن فيها، وعلامة النجمة Access-Control-Allow-Origin: * لا تعمل ببساطة بمجرد دخول الكوكيز في الصورة — المتصفحات تحجبها مباشرة. تناولنا الحل (عكس الـ Origin الحقيقي، مع SameSite=None) في الخطوة 3، لكن يستحق التكرار لأنه السبب الأكثر شيوعًا لأن يفقد شات بوت مستضاف ذاتيًا المحادثة بعد أول رسالة.
  4. عدم وجود بديل لـ "لا أعرف". ضمّن تعليمة في System Prompt تطلب من النموذج الاعتراف بعدم اليقين بدل اختلاق إجابة. الإجابة الخاطئة تُلحق بالثقة ضررًا أكبر بكثير من رد صادق يقول "دعني أوصلك بموظف بشري".
  5. عدم إعادة تغذية Vectorize بعد تحديث الأسئلة الشائعة. شات بوتك الحي لا يعرف إلا ما موجود في الفهرس المتجهي — إذا حدّثت صفحة الأسئلة الشائعة على موقعك ونسيت إعادة تحويلها إلى Embeddings، سيبقى البوت يجيب من بيانات قديمة إلى أجل غير مسمى.

الخطة المجانية في Cloudflare: ما الذي تحصل عليه فعليًا مقابل 0$

قبل أن تلتزم بهذا المسار، يستحق أن تعرف بالضبط أين تنتهي الخطة المجانية. من تجربتي الشخصية، هذه الحدود سخية بما يكفي لأغلبية المواقع الصغيرة والمتوسطة:

  • Workers: 100,000 طلب يوميًا
  • Workers AI: 10,000 "نيورون" يوميًا (تقريبًا بضع مئات من المحادثات القصيرة، حسب طول الرسائل)
  • Vectorize: 5 ملايين عملية استعلام/إدراج شهريًا
  • KV: 100,000 قراءة و1,000 كتابة يوميًا

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

الخصوصية والامتثال القانوني

إذا كان موقعك يخدم زوّارًا في الاتحاد الأوروبي أو المملكة المتحدة أو كاليفورنيا، فإن شات بوتًا يخزّن تاريخ المحادثات يحتاج بعض أساسيات الامتثال:

  • موافقة الكوكيز — كوكي widget_sid كوكي "ضروري بشدة" (يشغّل ميزة المحادثة نفسها)، لكن بانر موافقة الكوكيز في موقعك يجب أن يفصح عنه رغم ذلك. حدّث سياسة الخصوصية لديك لذكر الكوكي ومدة احتفاظه البالغة 14 يومًا.
  • مدة الاحتفاظ بالبيانات — مدة صلاحية 14 يومًا في KV تعني حذف المحادثات تلقائيًا. إذا احتجت احتفاظًا أطول لأغراض تحليلية، اجعل البيانات مجهولة الهوية وخزّنها بشكل منفصل مع سياسة احتفاظ واضحة.
  • الحق في الحذف — أضف نقطة نهاية DELETE /api/history تمسح سجل الزائر من KV، ليتمكن المستخدمون من ممارسة حقهم في النسيان بموجب المادة 17 من GDPR.
  • الأنظمة الإقليمية المشابهة — إذا كان جمهورك يشمل زوّارًا من الخليج، فتذكّر أن أنظمة مثل نظام حماية البيانات الشخصية السعودي (PDPL) أو قانون حماية البيانات في الإمارات تحمل مبادئ مشابهة — موافقة صريحة، وضوح في الغرض من التخزين، وحق طلب الحذف. الإعداد نفسه أعلاه يغطي أغلب هذه المتطلبات.

ماذا يحدث عندما تتجاوز الخطة المجانية

الخطة المجانية من Cloudflare سخية، لكن يستحق التخطيط للنمو. إليك شكل الخطط المدفوعة ومتى ستحتاجها:

الخدمة الحد المجاني بداية الخطة المدفوعة متى ستحتاجها
Workers 100 ألف طلب/يوم ~5$/شهريًا (Workers Paid) ~3,000 زائر/يوم
Workers AI 10 آلاف neuron/يوم ~0.001$/1000 neuron ~500 محادثة/يوم
Vectorize 5 ملايين عملية/شهر حسب الاستخدام ~50 ألف استعلام/يوم
KV 100 ألف قراءة + 1000 كتابة/يوم ~0.50$/شهريًا لكل مليون قراءة ~3,000 زائر/يوم

حتى خطة Workers Paid بـ5$ شهريًا أرخص بشكل هائل من أي شات بوت SaaS بنفس مستوى الزيارات. الميزة الأساسية أن التسعير يعتمد على الاستخدام الفعلي — تدفع فقط مقابل ما تستهلكه، وكودك الحالي يعمل بلا أي تعديل.

إلى أين تأخذ هذا المشروع بعد ذلك؟

بمجرد أن يعمل الـ widget الأساسي، هناك بضع ترقيات تستحق الاستكشاف. يمكنك استبدال Llama 3 بنموذج مفتوح آخر لمقارنة جودة الرد — مقارنتنا بين Qwen وGPT وGemini نقطة انطلاق جيدة لفهم المقايضات بين النماذج. وإذا أردت فهم آلية RAG بتعمّق أكبر من الشرح المختصر في بداية هذا الدليل، فقد خصصنا مقالًا كاملًا يشرح RAG بمشروع عملي. أما إذا كنت تفضّل تنظيم منطق الشات بوت بصريًا بدل كتابته يدويًا، فيغطي دليلنا حول أتمتة n8n بديلًا بدون كود يستحق المقارنة مع هذا النهج المبني يدويًا. وإذا كان الفرق بين "وكيل ذكاء اصطناعي (AI Agent)" وشات بوت بسيط يثير فضولك، فقد تناولنا هذا الفرق في دليل بناء فريق من وكلاء الذكاء الاصطناعي لموقعك مجانًا.

تريد إدارة إصدارات كود الـ Worker الخاص بك والتعاون عليه مع آخرين؟ دليلنا للمبتدئين في Git وGitHub يغطي هذا بالضبط، ويتماشى جيدًا مع رفع هذا المشروع إلى مستودع قبل أن تبدأ في تخصيصه أكثر.

خلاصة القول

ما بنيته هنا ليس لعبة تجريبية — إنه بديل حقيقي وقابل للعمل في الإنتاج مقابل أدوات تتقاضى عادة مئات الدولارات شهريًا. القطع (دالة Serverless، قاعدة بيانات متجهية، ومخزن مفتاح-قيمة) هي نفس اللبنات الأساسية التي تشغّل منتجات ذكاء اصطناعي أكبر بكثير؛ أنت فقط تشغّلها بحجم يصادف أنه يناسب الخطة المجانية من Cloudflare.

الأجزاء التي تتجاهلها معظم الشروحات — تقليم تاريخ المحادثة، معالجة CORS بشكل صحيح، منح النموذج إذنًا بقول "لا أعرف" — هي بالضبط التفاصيل التي تفصل بين نسخة تجريبية وشيء تثق فيه فعلًا على موقع حي. اضبط هذه التفاصيل بشكل صحيح، والفرق بين هذا وبين widget بـ99$ شهريًا من SaaS يتلخّص غالبًا في من يملك سكربت الإعداد.

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

📬

أعجبك الأسلوب العملي؟

انضم إلى مئات المشتركين واحصل على أحدث الأدلة التقنية والذكاء الاصطناعي والأمن السيبراني — مشاريع حقيقية لا نظريات — تصلك مباشرة على بريدك.

نعم، أشترك! ✉️

🔒 لا رسائل مزعجة أبداً. نحترم صندوق بريدك.

الأسئلة الشائعة

هذه الأسئلة الأكثر تكرارًا من القرّاء. إن لم تجد سؤالك هنا، اتركه في التعليقات وسأضيفه.

❓ هل شات بوت Cloudflare Workers AI مجاني حقًا؟

نعم، لمعظم المواقع منخفضة إلى متوسطة الزيارات. الخطة المجانية تشمل 100,000 طلب Worker، و10,000 نيورون ذكاء اصطناعي، و5 ملايين عملية Vectorize، و100,000 قراءة KV يوميًا. تحتاج زيارات حقيقية ومستمرة قبل أن تلامس أيًا من هذه السقوف، وحتى عندها، خطط Cloudflare المدفوعة تعتمد على الاستخدام الفعلي وليست رسومًا ثابتة لكل محادثة.

❓ هل أحتاج إلى إتقان JavaScript لأتّبع هذا الدليل؟

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

❓ ما هي تقنية RAG ولماذا يحتاجها هذا الشات بوت؟

RAG (اختصار Retrieval Augmented Generation) تعني أن الشات بوت يبحث عن معلومات دقيقة من ملف أسئلتك الشائعة قبل توليد الإجابة، بدل الاعتماد فقط على ما "يعرفه" نموذج اللغة أصلًا. بدونها، لن يعرف البوت شيئًا عن منتجك أو سياساتك أو أسعارك — سيتحدث بعموميات فقط.

❓ هل يمكنني استخدام نموذج ذكاء اصطناعي آخر بدل Llama 3؟

نعم. تستضيف Workers AI عدة نماذج مفتوحة غير Llama 3، ويمكنك أيضًا توجيه الطلبات لمزوّدين خارجيين مثل OpenAI أو Gemini إذا كنت مستعدًا للدفع مقابل كل رمز (Token) لجودة مختلفة. تبديل النموذج عادة يتطلب فقط تغيير اسم النموذج في استدعاء env.AI.run()، رغم أن تنسيق الـ prompt قد يختلف قليلًا بين النماذج.

❓ هل سيؤثر هذا الـ widget على سرعة موقعي؟

تأثير طفيف جدًا إذا نُفِّذ كما هو موضّح. سكربت الـ widget صغير، يُحمَّل بشكل غير متزامن (Async)، ولا يعطّل بقية الصفحة عن الظهور. العمل الأثقل — تشغيل نموذج الذكاء الاصطناعي والبحث في Vectorize — يحدث بالكامل على خوادم Cloudflare، وليس في متصفح الزائر.

❓ كيف أُبقي إجابات الشات بوت محدَّثة؟

كلما تغيّر محتوى أسئلتك الشائعة، أعد تشغيل سكربت التحويل والإدراج (Embedding وupsert) ضد Vectorize حتى تصبح الأزواج الجديدة قابلة للبحث. الشات بوت لا يعرف إلا ما هو مخزَّن في الفهرس المتجهي — تحديث صفحة الأسئلة الشائعة على موقعك وحده لا يُحدِّث ما يستطيع البوت استرجاعه.

❓ الشات بوت يعمل برسالة واحدة ثم "ينسى" المحادثة — لماذا؟

السبب غالبًا هو أن الكوكي لم يُضبط أصلًا، أو أن خاصية SameSite فيه خاطئة. تحقّق من DevTools ← Application ← Cookies. الحل: تأكّد أنك على HTTPS عند الاختبار (استخدم wrangler dev --remote بدل wrangler dev العادي)، وتأكّد أن متصفحك لا يحجب كوكيز الطرف الثالث.

📌 وجدتَ هذا الدليل مفيدًا؟ شاركه مع من سئم من دفع اشتراك شهري مقابل widget دردشة، واستكشف المزيد من أدلة الذكاء الاصطناعي والأتمتة العملية على وادي التكنولوجيا.

أضف وادي التكنولوجيا كمصدر مفضّل على جوجل

اجعل مقالاتنا تظهر لك أولًا في نتائج البحث والأخبار

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



    /*إشعار رسالة الكوكيز*/