كل درس عن الذكاء الاصطناعي قرأته هذا العام يبدأ غالبًا بالطريقة نفسها: احصل على مفتاح API، فعّل الفوترة، ثم أرسل بيانات مستخدميك إلى خادم طرف ثالث. هذا الدرس يفعل العكس تمامًا. في نهايته سيكون لديك تطبيق Next.js يشغّل نماذج تعلّم آلي حقيقية — تضمينات نصية، ونموذج دردشة لغوي، والتعرف على الكلام عبر Whisper — بالكامل داخل متصفح المستخدم، مع تسريع عبر معالج الرسوميات بفضل WebGPU.
المكتبة التي تجعل هذا ممكنًا هي Transformers.js من Hugging Face. وصلت اليوم إلى الإصدار الرابع، وتشغّل نماذج محوّلة إلى صيغة ONNX على WebAssembly أو WebGPU، وتدعم أكثر من 150 بنية نموذج، ويستخدمها أكثر من مليون مستخدم فريد شهريًا. واجهة WebGPU الخلفية توفّر استدلالًا أسرع غالبًا بعشر إلى مئة مرة من بديل WASM، وهي سرعة كافية لجعل النماذج اللغوية الصغيرة قابلة للاستخدام فعليًا على حواسيب المستهلكين.
لماذا يهم هذا؟ ثلاثة أسباب تكتسب أهمية خاصة إذا كنت تبني لمستخدمين في منطقة الشرق الأوسط وشمال إفريقيا:
- تكلفة صفرية لكل وحدة نصية. يُنزَّل النموذج مرة واحدة، يُخزَّن في ذاكرة المتصفح، وكل استدلال بعد ذلك مجاني. لا فواتير استخدام تتضخم مع نمو زياراتك.
- خصوصية كاملة. التسجيلات الصوتية والمستندات والاستعلامات لا تغادر الجهاز أبدًا. في مجالات الصحة والقانون والقطاع الحكومي ذات متطلبات إقامة البيانات الصارمة، هذه ليست ميزة إضافية — بل البنية الوحيدة المؤهلة أصلًا.
- مرونة دون اتصال. بعد التخزين المؤقت، تعمل النماذج مع الاتصالات المتقطعة، وفي القطارات، وخلف الجدران النارية المقيِّدة.
المتطلبات المسبقة
قبل البدء، تأكد من توفر:
- Node.js 20 أو أحدث مع npm أو pnpm
- معرفة أساسية بـ React و Next.js (نظام App Router)
- متصفح يدعم WebGPU: كروم أو إيدج 113 أو أحدث، أو إصدارات حديثة من فايرفوكس وسفاري مع تفعيل WebGPU
- جهاز بمعالج رسوميات (الرسوميات المدمجة تكفي — أجهزة Apple Silicon تعمل بامتياز)
لا حاجة لحساب Hugging Face ولا مفاتيح API ولا Python.
ما الذي ستبنيه
صفحة واحدة باسم «صندوق أدوات الذكاء الاصطناعي الخاص» بثلاثة تبويبات، كل منها يعرض خط معالجة مختلفًا:
- بحث دلالي — اكتب ملاحظات، حوّلها إلى متجهات بنموذج sentence-transformer، وابحث بالمعنى بدل الكلمات المفتاحية.
- دردشة محلية — محادثة متدفقة مع Qwen2.5-0.5B-Instruct، نموذج لغوي مضغوط يعمل على معالج الرسوميات لديك.
- تفريغ صوتي — سجّل صوتًا وفرّغه نصيًا عبر Whisper، دون اتصال بالإنترنت إطلاقًا.
كل الاستدلال يجري داخل Web Worker حتى لا تتجمد الواجهة أبدًا، وكل نموذج يُخزَّن في المتصفح بعد التنزيل الأول.
الخطوة 1: تهيئة المشروع
أنشئ مشروع Next.js جديدًا وثبّت Transformers.js:
npx create-next-app@latest browser-ai --typescript --app --tailwind
cd browser-ai
npm install @huggingface/transformersانتبه لاسم الحزمة: المكتبة الحديثة موجودة في @huggingface/transformers. أما حزمة @xenova/transformers القديمة فهي سلسلة الإصدار الثاني المهجورة — تجنّبها في المشاريع الجديدة.
تأتي Transformers.js مع ارتباطات Node.js مثل onnxruntime-node و sharp يجب ألا تُضمَّن في كود العميل. أخبر webpack بتجاوزها في next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
config.resolve.alias = {
...config.resolve.alias,
sharp$: false,
"onnxruntime-node$": false,
};
return config;
},
};
module.exports = nextConfig;دون هذا، يفشل البناء برسائل خطأ غامضة عن وحدات أصلية. هذا أكثر خطأ شائع في إعداد Transformers.js داخل Next.js.
الخطوة 2: افهم الأجهزة ومستويات الضغط
خياران يتحكمان في طريقة تشغيل النموذج، وضبطهما بشكل صحيح هو الفرق بين تطبيق سريع الاستجابة وتبويب متجمد.
device يحدد الواجهة الخلفية:
"wasm"— تشغيل WebAssembly على المعالج المركزي. يعمل في كل مكان لكنه الأبطأ."webgpu"— تسريع عبر معالج الرسوميات. أسرع بفارق هائل، لكنه يتطلب دعم المتصفح.
dtype يحدد مستوى الضغط الكمّي — أي مدى ضغط أوزان النموذج:
| dtype | الدقة | الاستخدام النموذجي |
|---|---|---|
fp32 | دقة كاملة 32 بت | الافتراضي على WebGPU، أكبر حجم تنزيل |
fp16 | نصف دقة | توازن جيد على WebGPU |
q8 | ضغط 8 بت | الافتراضي على WASM، صغير ودقيق |
q4 | ضغط 4 بت | النماذج اللغوية في المتصفح — أصغر حجم تنزيل |
لنموذج لغوي بنصف مليار معامل، يخفّض q4 حجم التنزيل إلى حوالي 350 ميغابايت — حجم كبير لأصل ويب، لكنه يُنزَّل مرة واحدة ويخزّنه المتصفح بشكل دائم.
كشف دعم WebGPU يتطلب سطرًا واحدًا. أنشئ lib/gpu.ts:
export async function detectWebGPU(): Promise<boolean> {
if (!("gpu" in navigator)) return false;
try {
const adapter = await (navigator as any).gpu.requestAdapter();
return adapter !== null;
} catch {
return false;
}
}سنستخدم هذا لاختيار webgpu عند توفره والرجوع إلى wasm خلاف ذلك، حتى يعمل التطبيق لدى كل زائر.
الخطوة 3: الـ Web Worker ونمط Singleton لخطوط المعالجة
استدلال النماذج عملية ثقيلة. إذا شغّلته على الخيط الرئيسي ستتأخر الكتابة وتتقطع الحركات وقد يعرض المتصفح تحذير «الصفحة لا تستجيب». الحل هو النمط القياسي في Transformers.js: Web Worker يملك النماذج، مع Singleton يضمن تحميل كل خط معالجة مرة واحدة فقط.
أنشئ app/worker.ts:
import {
pipeline,
TextStreamer,
env,
} from "@huggingface/transformers";
// Models always come from the Hugging Face Hub (cached after first load)
env.allowLocalModels = false;
// One singleton per task: load once, reuse forever
class Pipelines {
static instances: Record<string, Promise<any>> = {};
static get(task: string, model: string, options: object = {}) {
const key = `${task}:${model}`;
if (!(key in this.instances)) {
this.instances[key] = pipeline(task as any, model, {
...options,
progress_callback: (p: any) =>
self.postMessage({ status: "progress", task, data: p }),
});
}
return this.instances[key];
}
}
self.addEventListener("message", async (event: MessageEvent) => {
const { type, payload, device } = event.data;
try {
switch (type) {
case "embed": {
const extractor = await Pipelines.get(
"feature-extraction",
"mixedbread-ai/mxbai-embed-xsmall-v1",
{ device },
);
const output = await extractor(payload.texts, {
pooling: "mean",
normalize: true,
});
self.postMessage({
status: "complete",
type: "embed",
data: output.tolist(),
});
break;
}
case "chat": {
const generator = await Pipelines.get(
"text-generation",
"onnx-community/Qwen2.5-0.5B-Instruct",
{ device, dtype: "q4" },
);
const streamer = new TextStreamer(generator.tokenizer, {
skip_prompt: true,
callback_function: (text: string) =>
self.postMessage({ status: "token", data: text }),
});
const result = await generator(payload.messages, {
max_new_tokens: 512,
do_sample: false,
streamer,
});
self.postMessage({
status: "complete",
type: "chat",
data: result[0].generated_text.at(-1).content,
});
break;
}
case "transcribe": {
const transcriber = await Pipelines.get(
"automatic-speech-recognition",
"onnx-community/whisper-tiny.en",
{ device },
);
const output = await transcriber(payload.audio);
self.postMessage({
status: "complete",
type: "transcribe",
data: output.text,
});
break;
}
}
} catch (err: any) {
self.postMessage({ status: "error", data: err.message });
}
});ثلاثة أمور تستحق الانتباه:
progress_callbackيُستدعى أثناء تنزيل النموذج مع أسماء الملفات والنسب المئوية — تجربة مستخدم أساسية عند تنزيل 350 ميغابايت.TextStreamerيرسل كل وحدة نصية مولَّدة إلى الواجهة فور إنتاجها، فتبدو الدردشة حيّة بدل أن تتجمد ثم تُفرغ النتيجة دفعة واحدة.- خريطة الـ Singleton تعني أن التنقل بين التبويبات لن يعيد تنزيل النموذج أو تهيئته أبدًا.
الخطوة 4: خطاف React
الآن خطاف يتواصل مع الـ worker. أنشئ app/useAI.ts:
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { detectWebGPU } from "@/lib/gpu";
export function useAI() {
const worker = useRef<Worker | null>(null);
const [device, setDevice] = useState<"webgpu" | "wasm">("wasm");
const [progress, setProgress] = useState<string>("");
const [streamText, setStreamText] = useState<string>("");
const resolvers = useRef<Map<string, (data: any) => void>>(new Map());
useEffect(() => {
detectWebGPU().then((ok) => setDevice(ok ? "webgpu" : "wasm"));
worker.current = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
});
worker.current.addEventListener("message", (e: MessageEvent) => {
const { status, type, data } = e.data;
if (status === "progress" && data.status === "progress") {
setProgress(`${data.file}: ${Math.round(data.progress)}%`);
} else if (status === "token") {
setStreamText((prev) => prev + data);
} else if (status === "complete") {
setProgress("");
resolvers.current.get(type)?.(data);
}
});
return () => worker.current?.terminate();
}, []);
const run = useCallback(
(type: string, payload: object): Promise<any> => {
setStreamText("");
return new Promise((resolve) => {
resolvers.current.set(type, resolve);
worker.current?.postMessage({ type, payload, device });
});
},
[device],
);
return { run, device, progress, streamText };
}يوفر الخطاف أربعة أشياء: دالة run() قائمة على الوعود لأي مهمة، وdevice المكتشف، وprogress للتنزيل لعرض مؤشرات التحميل، وstreamText الذي يجمّع وحدات الدردشة النصية لحظيًا.
الخطوة 5: البحث الدلالي بالتضمينات
نموذج التضمين يحوّل النص إلى متجهات من 384 بُعدًا تتقارب فيها المعاني المتشابهة. وبما أن الـ worker يطبّع المتجهات مسبقًا، يصبح تشابه جيب التمام مجرد جداء نقطي. أنشئ app/components/SemanticSearch.tsx:
"use client";
import { useState } from "react";
function dot(a: number[], b: number[]): number {
return a.reduce((sum, v, i) => sum + v * b[i], 0);
}
export function SemanticSearch({ run }: { run: Function }) {
const [notes, setNotes] = useState<string[]>([
"The quarterly invoice for the Tunis office is due Friday",
"Couscous recipe: steam twice, never boil",
"WebGPU shaders compile asynchronously in Chrome",
]);
const [query, setQuery] = useState("");
const [results, setResults] = useState<
Array<[string, number]>
>([]);
async function search() {
const vectors: number[][] = await run("embed", {
texts: [query, ...notes],
});
const [queryVec, ...noteVecs] = vectors;
const scored = notes
.map((note, i): [string, number] => [note, dot(queryVec, noteVecs[i])])
.sort((a, b) => b[1] - a[1]);
setResults(scored);
}
return (
<div className="space-y-4">
<input
className="w-full rounded border p-2"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by meaning, e.g. 'cooking instructions'"
/>
<button onClick={search} className="rounded bg-blue-600 px-4 py-2 text-white">
Search
</button>
<ul>
{results.map(([note, score]) => (
<li key={note} className="border-b py-2">
<span className="font-mono text-sm text-gray-500">
{score.toFixed(3)}
</span>{" "}
{note}
</li>
))}
</ul>
</div>
);
}جرّب البحث عن "cooking instructions" — ستتصدر ملاحظة الكسكسي النتائج رغم أنها لا تشارك الاستعلام أي كلمة مفتاحية. هذا هو البحث الدلالي، يعمل محليًا، في أجزاء من الثانية، على نموذج نُزِّل في ثوانٍ.
الخطوة 6: دردشة متدفقة مع نموذج لغوي محلي
تبويب الدردشة يرسل سجل الرسائل ويعرض الوحدات النصية أثناء تدفقها. أنشئ app/components/LocalChat.tsx:
"use client";
import { useState } from "react";
type Message = { role: string; content: string };
export function LocalChat({
run,
streamText,
}: {
run: Function;
streamText: string;
}) {
const [messages, setMessages] = useState<Message[]>([
{ role: "system", content: "You are a concise, helpful assistant." },
]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
async function send() {
const next = [...messages, { role: "user", content: input }];
setMessages(next);
setInput("");
setBusy(true);
const reply: string = await run("chat", { messages: next });
setMessages([...next, { role: "assistant", content: reply }]);
setBusy(false);
}
return (
<div className="space-y-4">
{messages
.filter((m) => m.role !== "system")
.map((m, i) => (
<p key={i} className={m.role === "user" ? "text-right" : ""}>
<strong>{m.role}:</strong> {m.content}
</p>
))}
{busy && streamText && (
<p>
<strong>assistant:</strong> {streamText}
</p>
)}
<div className="flex gap-2">
<input
className="flex-1 rounded border p-2"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !busy && send()}
/>
<button
onClick={send}
disabled={busy}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
Send
</button>
</div>
</div>
);
}الرسالة الأولى تطلق تنزيل نموذج q4 (حوالي 350 ميغابايت — اعرض نص progress من الخطاف بشكل بارز). بعدها يُحمَّل النموذج من الذاكرة المؤقتة في بضع ثوانٍ، وعلى WebGPU يولّد نموذج بنصف مليار معامل النص بوتيرة عملية جدًا على الحواسيب العادية.
نموذج Qwen2.5-0.5B صغير — توقّع ملخصات وإعادة صياغة وأسئلة وأجوبة مقبولة، لا استدلالًا عميقًا. لجودة أعلى، استبدله بنموذج أكبر من مجتمع onnx-community مع إبقاء الكود نفسه؛ لن يتغير سوى معرّف النموذج وحجم التنزيل.
الخطوة 7: التفريغ الصوتي مع Whisper
التبويب الأخير يسجّل صوت الميكروفون ويمرره إلى Whisper. التفصيل الحاسم: يتوقع Whisper صوتًا أحادي القناة بتردد 16 كيلوهرتز بصيغة Float32، لذا نفك ترميز التسجيل عبر AudioContext مثبَّت على 16000 هرتز. أنشئ app/components/Transcriber.tsx:
"use client";
import { useRef, useState } from "react";
export function Transcriber({ run }: { run: Function }) {
const recorder = useRef<MediaRecorder | null>(null);
const chunks = useRef<Blob[]>([]);
const [recording, setRecording] = useState(false);
const [text, setText] = useState("");
async function start() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
recorder.current = new MediaRecorder(stream);
chunks.current = [];
recorder.current.ondataavailable = (e) => chunks.current.push(e.data);
recorder.current.onstop = async () => {
const blob = new Blob(chunks.current);
const ctx = new AudioContext({ sampleRate: 16000 });
const buffer = await ctx.decodeAudioData(await blob.arrayBuffer());
const audio = buffer.getChannelData(0); // mono Float32Array
const result: string = await run("transcribe", { audio });
setText(result);
stream.getTracks().forEach((t) => t.stop());
};
recorder.current.start();
setRecording(true);
}
function stop() {
recorder.current?.stop();
setRecording(false);
}
return (
<div className="space-y-4">
<button
onClick={recording ? stop : start}
className={`rounded px-4 py-2 text-white ${
recording ? "bg-red-600" : "bg-blue-600"
}`}
>
{recording ? "Stop" : "Record"}
</button>
{text && <p className="rounded bg-gray-100 p-4">{text}</p>}
</div>
);
}نموذج whisper-tiny.en لا يتجاوز حجمه 40 ميغابايت تقريبًا ويفرّغ المقاطع القصيرة في أقل من ثانية بكثير على WebGPU. صوت مستخدميك لا يلمس أي خادم — ضمانة ذات وزن حقيقي للإملاء الطبي والمذكرات القانونية وأي سير عمل خاضع للتنظيم.
أخيرًا، اربط المكونات الثلاثة في app/page.tsx بحالة تبويبات بسيطة، ومرر إليها run و streamText، واعرض مؤشري device و progress في الترويسة. الخطاف يتكفل بالباقي.
اختبار التنفيذ
- شغّل
npm run devوافتح كروم 113 أو أحدث. - يجب أن تعرض الترويسة webgpu — إن ظهرت wasm، تحقق من حالة WebGPU في
chrome://gpu. - في تبويب البحث، يُنزَّل نموذج التضمين (حوالي 30 ميغابايت) مع تقدم مرئي؛ وتعيد الاستعلامات نتائج مرتبة.
- في أدوات المطور، انتقل إلى Application ثم Cache storage — سترى
transformers-cacheيحتوي ملفات ONNX. أعد تحميل الصفحة: ستُهيأ النماذج الآن من الذاكرة المؤقتة دون أي حركة شبكة. - فعّل وضع Offline في خانق الشبكة بأدوات المطور بعد التحميل الأول — كل شيء يستمر في العمل. وهذا هو جوهر الفكرة.
استكشاف الأخطاء
فشل البناء بذكر sharp أو onnxruntime-node. تجاوزت أسماء webpack المستعارة في الخطوة 1. إنها إلزامية للاستخدام في جهة العميل.
navigator.gpu غير معرّف. المتصفح لا يدعم WebGPU، أو الصفحة مقدَّمة عبر HTTP عادي. يتطلب WebGPU سياقًا آمنًا — localhost مقبول، أما عنوان IP في الشبكة المحلية فلا.
أول رد دردشة يستغرق دقائق. هذا تنزيل q4 لمرة واحدة. اعرض رد نداء progress في الواجهة ليرى المستخدمون نسب التنزيل بدل زر ميت.
انهيار بسبب نفاد الذاكرة على الهاتف. نموذج لغوي بحجم 350 ميغابايت أثقل من قدرة كثير من الهواتف. اكشف الذاكرة عبر navigator.deviceMemory وقيّد تبويب الدردشة، أو وفّر نموذجًا أصغر.
تفريغ نصي مشوّه. صوتك ليس أحادي القناة بتردد 16 كيلوهرتز. فك الترميز دائمًا عبر new AudioContext({ sampleRate: 16000 }) ومرر القناة 0.
الخطوات التالية
- أضف RAG على الجهاز: قسّم المستندات إلى مقاطع، ضمّنها، خزّن المتجهات في IndexedDB، وغذِّ أفضل النتائج في موجّه الدردشة — خط استرجاع خاص بالكامل.
- جرّب Whisper متعدد اللغات (
onnx-community/whisper-small) لتفريغ الصوت العربي والفرنسي لمستخدمي منطقة الشرق الأوسط وشمال إفريقيا. - استكشف دليل WebGPU ومنظمة
onnx-communityعلى Hugging Face لمئات النماذج المحوّلة الجاهزة. - اقرن هذا الدرس مع درس روبوت الدردشة المحلي مع Ollama لمقارنة الاستدلال في المتصفح بالاستدلال على خادم ذاتي الاستضافة، أو دليل Vercel AI Gateway للمسار السحابي الهجين.
الخلاصة
بنيت تطبيق Next.js يضمّن ويدردش ويفرّغ الصوت دون استدعاء استدلال واحد من جهة الخادم. البنية صغيرة وقابلة للتكرار: Web Worker يملك خطوط معالجة Singleton، و WebGPU مع تراجع إلى WASM، ونماذج مضغوطة يخزّنها المتصفح، وخطاف قائم على الوعود يربط الـ worker بالواجهة.
الذكاء الاصطناعي داخل المتصفح لن يحل محل نماذج السحابة الرائدة في الاستدلال المعقد. لكن في التضمينات والتفريغ الصوتي والتصنيف والتوليد الخفيف — المهام التي تشكّل معظم ميزات الذكاء الاصطناعي في الإنتاج — يقدّم ما لا تستطيع أي واجهة API تقديمه: تكلفة حدية صفرية، وخصوصية تامة، وعمل دون اتصال. وللمطورين الذين يخدمون أسواقًا تشكّل فيها سيادة البيانات وتكاليف النطاق الترددي قيودًا يومية، هذه ليست خدعة عرض تقني، بل بنية تستحق الإطلاق فعلًا.