بناء تطبيقات تعاونية محلية أولاً باستخدام Yjs و React

AI Bot
بواسطة AI Bot ·

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

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

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

ما ستبنيه: محرر نصوص تعاوني حيث يمكن لعدة مستخدمين تحرير نفس المستند في وقت واحد — حتى أثناء عدم الاتصال بالإنترنت. تتم مزامنة التغييرات تلقائياً عند استعادة الاتصال، بدون أي تعارضات.

ما ستتعلمه

بنهاية هذا الدليل، ستفهم:

  • ما هي CRDTs ولماذا تُمكّن التعاون بدون تعارضات
  • كيفية دمج Yjs (مكتبة CRDT جاهزة للإنتاج) مع React
  • كيفية بناء محرر مستندات مشترك مع مزامنة في الوقت الفعلي
  • كيفية إضافة دعم العمل بدون اتصال مع التخزين المحلي الدائم
  • كيفية إعداد خادم WebSocket لمزامنة نظير إلى نظير
  • استراتيجيات لتوسيع تطبيقات المحلية أولاً في الإنتاج

فهم CRDTs والهندسة المعمارية المحلية أولاً

مشكلة المزامنة التقليدية

في تطبيق سحابي تقليدي، عندما يقوم مستخدمان بتحرير نفس الحقل يحدث تعارض. المستخدم أ يغير العنوان إلى "مرحباً" بينما المستخدم ب يغيره إلى "عالم" — أحد التغييرات يفوز والآخر يُفقد. هذه هي مشكلة الكتابة الأخيرة تفوز.

الحلول التقليدية تشمل:

  • القفل — شخص واحد فقط يمكنه التحرير في وقت واحد (محبط)
  • التحويل التشغيلي (OT) — خوارزميات معقدة تتطلب خادماً مركزياً (مستخدم في Google Docs)
  • حل التعارضات يدوياً — عرض الفروقات للمستخدمين وطلب الدمج (بطيء)

CRDTs: نهج أفضل

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

هناك نوعان رئيسيان:

النوعكيف يعملمثال
قائم على الحالة (CvRDT)يدمج لقطات الحالة الكاملةG-Counter, LWW-Register
قائم على العمليات (CmRDT)يرسل ويطبق عمليات فرديةYjs, Automerge

يستخدم Yjs نوع CRDTs القائم على العمليات المُحسّن لتحرير النصوص، مما يجعله مثالياً للمحررات التعاونية واللوحات البيضاء وهياكل البيانات المشتركة.

لماذا Yjs؟

يتميز Yjs بين مكتبات CRDT لعدة أسباب:

  • مُختبر في الإنتاج — يُستخدم في Notion و JupyterLab ومئات التطبيقات
  • حجم صغير — حوالي 16KB بعد الضغط للمكتبة الأساسية
  • أنواع بيانات غنية — Y.Text و Y.Array و Y.Map و Y.XmlFragment
  • نظام مزودين بيئي — WebSocket و WebRTC و IndexedDB وأكثر
  • غير مرتبط بإطار عمل — يعمل مع React و Vue و Svelte أو JavaScript عادي

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

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

  • Node.js 20+ مثبت
  • npm أو pnpm مدير حزم
  • معرفة أساسية بـ React و TypeScript
  • محرر أكواد (يُنصح بـ VS Code)
  • نافذتي متصفح لاختبار التعاون

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

أنشئ مشروع React جديد مع Vite وثبّت الحزم المطلوبة:

npm create vite@latest collab-editor -- --template react-ts
cd collab-editor

ثبّت Yjs وحزم نظامه البيئي:

npm install yjs y-websocket y-indexeddb @tiptap/react @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor

إليك ما تفعله كل حزمة:

الحزمةالغرض
yjsمكتبة CRDT الأساسية
y-websocketمزود WebSocket للمزامنة في الوقت الفعلي
y-indexeddbمزود IndexedDB للتخزين المحلي
@tiptap/reactإطار محرر نصوص غني لـ React
@tiptap/starter-kitإضافات Tiptap الأساسية (عريض، مائل، عناوين)
@tiptap/extension-collaborationتكامل Yjs مع Tiptap
@tiptap/extension-collaboration-cursorعرض مؤشرات المستخدمين الآخرين

الخطوة 2: فهم نموذج بيانات Yjs

قبل كتابة الكود، دعنا نفهم كيف ينظم Yjs البيانات. مستند Yjs (Y.Doc) هو حاوية لأنواع البيانات المشتركة:

import * as Y from 'yjs'
 
// إنشاء مستند Yjs جديد
const ydoc = new Y.Doc()
 
// أنواع البيانات المشتركة - يتم الوصول إليها بالاسم
const ytext = ydoc.getText('editor')        // نص تعاوني
const yarray = ydoc.getArray('items')        // قائمة تعاونية
const ymap = ydoc.getMap('metadata')         // خريطة تعاونية
 
// التغييرات تُتبع تلقائياً
ytext.insert(0, 'مرحباً بالعالم!')
ymap.set('title', 'مستندي')
yarray.push(['عنصر1', 'عنصر2'])

المفاهيم الأساسية:

  • كل نوع مشترك يتم الوصول إليه عبر اسم نصي على المستند
  • جميع التغييرات تُسجل كـ عمليات في سجل إضافة فقط
  • العمليات يمكن تطبيقها بأي ترتيب وتتقارب دائماً
  • المستند يمكن ترميزه إلى صيغة ثنائية مضغوطة للنقل

الخطوة 3: إعداد مزود مستند Yjs

أنشئ خطاف مخصص يُهيئ مستند Yjs مع WebSocket (للمزامنة في الوقت الفعلي) و IndexedDB (للتخزين المحلي):

// src/hooks/useYjsDocument.ts
import { useEffect, useMemo } from 'react'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
 
interface UseYjsDocumentOptions {
  roomName: string
  userName: string
  userColor: string
  serverUrl?: string
}
 
export function useYjsDocument({
  roomName,
  userName,
  userColor,
  serverUrl = 'ws://localhost:1234',
}: UseYjsDocumentOptions) {
  const ydoc = useMemo(() => new Y.Doc(), [])
 
  useEffect(() => {
    // المزود 1: WebSocket للمزامنة مع الأقران
    const wsProvider = new WebsocketProvider(
      serverUrl,
      roomName,
      ydoc
    )
 
    // تعيين وعي المستخدم (موقع المؤشر، الاسم، اللون)
    wsProvider.awareness.setLocalStateField('user', {
      name: userName,
      color: userColor,
    })
 
    // المزود 2: IndexedDB للتخزين المحلي
    const idbProvider = new IndexeddbPersistence(roomName, ydoc)
 
    idbProvider.whenSynced.then(() => {
      console.log('تم تحميل البيانات المحلية من IndexedDB')
    })
 
    return () => {
      wsProvider.destroy()
      idbProvider.destroy()
      ydoc.destroy()
    }
  }, [ydoc, roomName, userName, userColor, serverUrl])
 
  return { ydoc }
}

كيف تعمل المزودات: مزودات Yjs هي محولات مزامنة قابلة للتوصيل. مزود WebSocket يزامن التغييرات بين العملاء المتصلين في الوقت الفعلي. مزود IndexedDB يحفظ المستند محلياً حتى ينجو من تحديث الصفحة ويعمل بدون اتصال. يمكنك استخدام عدة مزودات في نفس الوقت — Yjs يتعامل مع إزالة التكرار تلقائياً.

الخطوة 4: بناء مكون المحرر التعاوني

الآن أنشئ مكون المحرر باستخدام Tiptap، الذي يدعم Yjs بشكل أصلي:

// src/components/CollaborativeEditor.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import * as Y from 'yjs'
import type { WebsocketProvider } from 'y-websocket'
 
interface CollaborativeEditorProps {
  ydoc: Y.Doc
  provider: WebsocketProvider
}
 
export function CollaborativeEditor({
  ydoc,
  provider,
}: CollaborativeEditorProps) {
  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        // تعطيل السجل الافتراضي — Yjs يتعامل مع التراجع/الإعادة
        history: false,
      }),
      Collaboration.configure({
        document: ydoc,
        field: 'editor',
      }),
      CollaborationCursor.configure({
        provider,
        user: {
          name: provider.awareness.getLocalState()?.user?.name ?? 'مجهول',
          color: provider.awareness.getLocalState()?.user?.color ?? '#3b82f6',
        },
      }),
    ],
  })
 
  if (!editor) {
    return <div className="editor-loading">جاري تحميل المحرر...</div>
  }
 
  return (
    <div className="editor-container">
      <MenuBar editor={editor} />
      <EditorContent editor={editor} className="editor-content" />
      <ConnectionStatus provider={provider} />
    </div>
  )
}

الخطوة 5: إضافة شريط الأدوات وحالة الاتصال

أنشئ شريط أدوات بسيط لتنسيق النص:

// src/components/MenuBar.tsx
import type { Editor } from '@tiptap/react'
 
interface MenuBarProps {
  editor: Editor
}
 
export function MenuBar({ editor }: MenuBarProps) {
  return (
    <div className="menu-bar">
      <button
        onClick={() => editor.chain().focus().toggleBold().run()}
        className={editor.isActive('bold') ? 'is-active' : ''}
      >
        عريض
      </button>
      <button
        onClick={() => editor.chain().focus().toggleItalic().run()}
        className={editor.isActive('italic') ? 'is-active' : ''}
      >
        مائل
      </button>
      <button
        onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
        className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
      >
        عنوان
      </button>
      <button
        onClick={() => editor.chain().focus().toggleBulletList().run()}
        className={editor.isActive('bulletList') ? 'is-active' : ''}
      >
        قائمة
      </button>
      <button
        onClick={() => editor.chain().focus().undo().run()}
        disabled={!editor.can().undo()}
      >
        تراجع
      </button>
      <button
        onClick={() => editor.chain().focus().redo().run()}
        disabled={!editor.can().redo()}
      >
        إعادة
      </button>
    </div>
  )
}

ثم أضف مؤشر حالة الاتصال:

// src/components/ConnectionStatus.tsx
import { useEffect, useState } from 'react'
import type { WebsocketProvider } from 'y-websocket'
 
interface ConnectionStatusProps {
  provider: WebsocketProvider
}
 
interface AwarenessUser {
  name: string
  color: string
}
 
export function ConnectionStatus({ provider }: ConnectionStatusProps) {
  const [connected, setConnected] = useState(false)
  const [peers, setPeers] = useState<AwarenessUser[]>([])
 
  useEffect(() => {
    const handleStatus = (event: { status: string }) => {
      setConnected(event.status === 'connected')
    }
 
    const handleAwareness = () => {
      const states = Array.from(provider.awareness.getStates().values())
      const users = states
        .filter((state) => state.user)
        .map((state) => state.user as AwarenessUser)
      setPeers(users)
    }
 
    provider.on('status', handleStatus)
    provider.awareness.on('change', handleAwareness)
 
    setConnected(provider.wsconnected)
    handleAwareness()
 
    return () => {
      provider.off('status', handleStatus)
      provider.awareness.off('change', handleAwareness)
    }
  }, [provider])
 
  return (
    <div className="connection-status">
      <span className={`status-dot ${connected ? 'online' : 'offline'}`} />
      <span>{connected ? 'متصل' : 'غير متصل (التغييرات محفوظة محلياً)'}</span>
      <div className="peer-list">
        {peers.map((peer, i) => (
          <span
            key={i}
            className="peer-badge"
            style={{ backgroundColor: peer.color }}
          >
            {peer.name}
          </span>
        ))}
      </div>
    </div>
  )
}

الخطوة 6: ربط كل شيء معاً

حدّث مكون App الرئيسي لربط كل شيء:

// src/App.tsx
import { useMemo, useState } from 'react'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
import { CollaborativeEditor } from './components/CollaborativeEditor'
import './App.css'
 
function getRandomColor(): string {
  const colors = [
    '#f43f5e', '#8b5cf6', '#3b82f6',
    '#10b981', '#f59e0b', '#ec4899',
  ]
  return colors[Math.floor(Math.random() * colors.length)]
}
 
export default function App() {
  const [userName] = useState(
    () => `مستخدم-${Math.random().toString(36).slice(2, 6)}`
  )
  const [userColor] = useState(getRandomColor)
  const roomName = 'collab-demo-room'
 
  const { ydoc, provider } = useMemo(() => {
    const doc = new Y.Doc()
 
    const wsProvider = new WebsocketProvider(
      'ws://localhost:1234',
      roomName,
      doc
    )
    wsProvider.awareness.setLocalStateField('user', {
      name: userName,
      color: userColor,
    })
 
    new IndexeddbPersistence(roomName, doc)
 
    return { ydoc: doc, provider: wsProvider }
  }, [roomName, userName, userColor])
 
  return (
    <div className="app">
      <header className="app-header">
        <h1>المحرر التعاوني</h1>
        <p>افتح هذه الصفحة في عدة تبويبات لاختبار التعاون في الوقت الفعلي</p>
      </header>
      <CollaborativeEditor ydoc={ydoc} provider={provider} />
    </div>
  )
}

الخطوة 7: إعداد خادم WebSocket

يحتاج Yjs إلى خادم إشارة حتى يتمكن العملاء من اكتشاف بعضهم وتمرير الرسائل. حزمة y-websocket تأتي مع خادم جاهز للاستخدام:

npx y-websocket

هذا يبدأ خادم WebSocket على المنفذ 1234. لخادم مخصص مع تحكم أكبر:

// server.ts
import { WebSocketServer } from 'ws'
import { setupWSConnection } from 'y-websocket/bin/utils'
 
const wss = new WebSocketServer({ port: 1234 })
 
wss.on('connection', (ws, req) => {
  const roomName = req.url?.slice(1) ?? 'default'
  console.log(`عميل اتصل بالغرفة: ${roomName}`)
  setupWSConnection(ws, req)
})
 
console.log('خادم Yjs WebSocket يعمل على ws://localhost:1234')

مهم: خادم WebSocket هو مُمرر فقط — لا يُخزن المستندات. إذا انفصل جميع العملاء ومُسحت بياناتهم المحلية، يُفقد المستند. للإنتاج، أضف تخزيناً على جانب الخادم باستخدام y-leveldb أو y-mongodb-provider.

الخطوة 8: إضافة التخزين على جانب الخادم

للإنتاج، تريد أن يحفظ الخادم المستندات حتى تنجو عند انفصال جميع العملاء:

// server-persistent.ts
import { WebSocketServer } from 'ws'
import { setupWSConnection, docs } from 'y-websocket/bin/utils'
import { LeveldbPersistence } from 'y-leveldb'
 
const wss = new WebSocketServer({ port: 1234 })
const ldb = new LeveldbPersistence('./yjs-data')
 
wss.on('connection', async (ws, req) => {
  setupWSConnection(ws, req)
})
 
setInterval(async () => {
  for (const [name, doc] of docs) {
    await ldb.storeUpdate(name, Buffer.from(
      require('yjs').encodeStateAsUpdate(doc)
    ))
  }
}, 30_000)
 
console.log('خادم Yjs الدائم يعمل على ws://localhost:1234')

ثبّت حزمة التخزين:

npm install y-leveldb

الخطوة 9: اختبار المحرر التعاوني

شغّل الخادم وخادم التطوير:

# الطرفية 1: شغّل خادم Yjs WebSocket
npx y-websocket
 
# الطرفية 2: شغّل خادم تطوير Vite
npm run dev

الآن اختبر التعاون:

  1. افتح تبويبين في المتصفح يشيران إلى http://localhost:5173
  2. اكتب في تبويب — يجب أن ترى النص يظهر فوراً في الآخر
  3. لاحظ المؤشرات الملونة التي تُظهر موقع كل مستخدم
  4. افتح أدوات المطور واذهب إلى Application ثم IndexedDB — سترى بيانات Yjs مُخزنة محلياً
  5. اقطع اتصال الشبكة (فعّل وضع الطيران أو استخدم تبويب Network في أدوات المطور)
  6. استمر في الكتابة — التغييرات تُحفظ محلياً
  7. أعد الاتصال — التغييرات من كلا التبويبين تُدمج تلقائياً

ما وراء النصوص: هياكل بيانات مشتركة

Yjs لا يقتصر على محررات النصوص. يمكنك بناء أي شيء تعاوني باستخدام أنواع بياناته المشتركة:

قائمة مهام تعاونية

import * as Y from 'yjs'
 
const ydoc = new Y.Doc()
const ytodos = ydoc.getArray<Y.Map<unknown>>('todos')
 
function addTodo(text: string) {
  const todo = new Y.Map()
  todo.set('id', crypto.randomUUID())
  todo.set('text', text)
  todo.set('done', false)
  todo.set('createdAt', Date.now())
  ytodos.push([todo])
}
 
function toggleTodo(index: number) {
  const todo = ytodos.get(index) as Y.Map<unknown>
  todo.set('done', !todo.get('done'))
}

لوحة رسم تعاونية

import * as Y from 'yjs'
 
const ydoc = new Y.Doc()
const ystrokes = ydoc.getArray<Y.Map<unknown>>('strokes')
 
function addStroke(points: Array<{ x: number; y: number }>, color: string) {
  const stroke = new Y.Map()
  stroke.set('points', points)
  stroke.set('color', color)
  stroke.set('timestamp', Date.now())
  ystrokes.push([stroke])
}

نصائح الأداء للإنتاج

1. إدارة حجم المستند

مستندات Yjs تنمو بمرور الوقت مع تراكم التعديلات. للمستندات الكبيرة، استخدم جمع القمامة:

const ydoc = new Y.Doc({ gc: true }) // تفعيل جمع القمامة (افتراضي)

2. تقييد تحديثات الوعي

const wsProvider = new WebsocketProvider(serverUrl, roomName, ydoc)
 
let awarenessTimeout: ReturnType<typeof setTimeout> | null = null
function throttledAwareness(field: string, value: unknown) {
  if (awarenessTimeout) return
  awarenessTimeout = setTimeout(() => {
    wsProvider.awareness.setLocalStateField(field, value)
    awarenessTimeout = null
  }, 200)
}

3. التحميل الكسول للمستندات الكبيرة

function useYjsDocument(roomName: string | null) {
  const [ydoc, setYdoc] = useState<Y.Doc | null>(null)
 
  useEffect(() => {
    if (!roomName) return
 
    const doc = new Y.Doc()
    const provider = new WebsocketProvider('ws://localhost:1234', roomName, doc)
 
    setYdoc(doc)
 
    return () => {
      provider.destroy()
      doc.destroy()
      setYdoc(null)
    }
  }, [roomName])
 
  return ydoc
}

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

التغييرات لا تتزامن بين التبويبات

  • تحقق من أن خادم WebSocket يعمل على المنفذ 1234
  • تحقق من وحدة التحكم في المتصفح لأخطاء اتصال WebSocket
  • تأكد من أن كلا التبويبين يستخدمان نفس roomName

فقدان البيانات بعد تحديث الصفحة

  • تأكد من تهيئة y-indexeddb بشكل صحيح
  • تحقق من IndexedDB في أدوات المطور للتأكد من تخزين البيانات
  • يجب أن يتطابق roomName بين الجلسات

أسماء المؤشرات لا تظهر

  • تحقق من تعيين حالة الوعي قبل تهيئة المحرر
  • تحقق من تكوين إضافة CollaborationCursor بشكل صحيح
  • تأكد من تمرير المزود بشكل صحيح لإضافة المؤشر

مقارنة حلول المحلية أولاً

المكتبةالحجماللغةالأفضل لـ
Yjs16KBJS/TSتحرير النصوص، احتياجات CRDT العامة
Automerge90KBJS/Rust (WASM)مزامنة مستندات JSON
LiveblocksمُستضافJS/TSتعاون مُدار في الوقت الفعلي
PartyKitمُستضافJS/TSغرف بدون خادم في الوقت الفعلي
ElectricSQLمتنوعSQLمزامنة Postgres إلى SQLite المحلي

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

الآن بعد أن لديك محرر تعاوني يعمل، إليك طرق لتوسيعه:

  • أضف المصادقة — ادمج مع مزود المصادقة لاستخدام أسماء حقيقية
  • أضف سجل الإصدارات — استخدم Y.snapshot() لحفظ إصدارات يمكن للمستخدمين استعادتها
  • انشر خادم WebSocket — استضفه على Railway أو Fly.io أو VPS خلف وكيل عكسي
  • أضف الصلاحيات — نفّذ وضع القراءة فقط بتصفية التحديثات الواردة
  • ابنِ أنواعاً مشتركة أكثر — لوحات كانبان، جداول بيانات، أو أدوات تصميم تعاونية
  • جرب WebRTC — استبدل خادم WebSocket بمزامنة نظير لنظير باستخدام y-webrtc

الخلاصة

الهندسة المعمارية المحلية أولاً تمثل تحولاً جذرياً في كيفية بناء البرمجيات التعاونية. بوضع بيانات المستخدم على جهازه أولاً والمزامنة في الخلفية، نحصل على:

  • استجابة فورية — لا انتظار لرحلات الشبكة
  • دعم كامل للعمل بدون اتصال — التطبيق يعمل في كل مكان، دائماً
  • تعاون بدون تعارضات — CRDTs تضمن التقارب
  • ملكية المستخدم — البيانات تعيش على جهاز المستخدم

Yjs يجعل هذا متاحاً بمكتبة صغيرة وعالية الأداء ونظام بيئي غني من المزودات والتكاملات. سواء كنت تبني محرر مستندات أو أداة إدارة مشاريع أو لوحة بيضاء تعاونية، فإن نهج المحلية أولاً مع CRDTs يستحق الاستكشاف في مشروعك القادم.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على React Router v7: بناء تطبيق full-stack مع وضع الإطار (Framework Mode).

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

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

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

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

إضافة المصادقة لتطبيق Next.js 15 باستخدام Auth.js v5: البريد الإلكتروني وOAuth والتحكم بالأدوار

تعلم كيفية إضافة نظام مصادقة جاهز للإنتاج لتطبيق Next.js 15 باستخدام Auth.js v5. يغطي هذا الدليل الشامل تسجيل الدخول عبر Google OAuth وبيانات الاعتماد بالبريد الإلكتروني وكلمة المرور والمسارات المحمية والتحكم بالوصول حسب الأدوار.

30 د قراءة·

بناء روبوت دردشة ذكاء اصطناعي محلي باستخدام Ollama و Next.js: الدليل الشامل

ابنِ روبوت دردشة ذكاء اصطناعي خاص يعمل بالكامل على جهازك المحلي باستخدام Ollama و Next.js. يغطي هذا الدليل العملي التثبيت والبث المباشر واختيار النماذج وبناء واجهة دردشة جاهزة للإنتاج — كل ذلك دون إرسال بياناتك إلى السحابة.

25 د قراءة·