لنبنِ دردشة ويب متدفقة (Express Proxy + JS عادي)
في هذا الدليل ستبني صفحة دردشة صغيرة في حوالي 100 سطر. خادم Express صغير يحتفظ بمفتاحك السري ويعيد توجيه الطلبات إلى Uncensored Chatbot API، بينما يقرأ الواجهة الأمامية البسيطة بلغة JavaScript الرد المتدفق كلمة بكلمة. لا إطار عمل، لا خطوة بناء، ومفتاحك لا يلمس المتصفح أبداً.
تم التحديث
نقاط رئيسية
- لا تستدعي API أبداً من كود المتصفح؛ مسار الخادم يبقي المفتاح خاصاً ويسمح لك بتحقق المدخلات.
- يحتاج الوسيط فقط إلى تمرير الأحداث المرسلة من الخادم؛ تقوم الصفحة بتحليل أسطر البيانات حتى [DONE].
- استخدم textContent أثناء البث ونظف النص قبل عرض Markdown.
- حدد طول السجل و max_tokens على الخادم حتى لا ينفق مستخدم واحد رصيدك بالكامل.
ما الذي نبنيه، ولماذا نحتاج الوسيط
تخيل صفحة واحدة تحتوي على مربع نص وسجل قابل للتمرير. تكتب سطراً، ترسل الصفحة المحادثة إلى /api/chat على خادمك الخاص، ويقوم خادمك بإعادة توجيهها إلى الخادم البعيد مع إرفاق المفتاح الحقيقي. يعود الرد متدفقاً، ويظهر كل جزء في اللحظة التي يصل فيها. سنمنح البوت بعض الشخصية، حارس منارة يُدعى Wren، حتى يبدو العرض التوضيحي حياً.
تحتاج إلى Node 18 أو أحدث (لـ fetch المدمج) ومفتاح API. إذا لم يكن لديك واحد بعد، احصل على رصيد تجريبي مجاني من صفحة المفاتيح: تحصل الحسابات الجديدة على $0.50 رصيد لمدة 7 أيام، دون الحاجة إلى تفاصيل الدفع. عنوان URL الأساسي هو https://api.uncensoredchatbotapi.com/v1 ومعرف النموذج هو uncensored.
لماذا تحتاج الوسيط
من المغري لصق المفتاح في كود الواجهة الأمامية واستدعاء API مباشرة. لا تفعل ذلك. أي شيء يتم إرساله إلى المتصفح يمكن قراءته من قبل أي شخص يفتح أدوات المطور، وتسريب المفتاح يعني إفراغ الرصيد. الوسيط يحل هذه المشكلة ويمنحك ثلاث فوائد إضافية.
- السرية. يعيش المفتاح في متغير بيئة على الخادم.
- التحكم. أنت تقرر الموجّه النظامي، طول السجل و
max_tokens، لذا لا يمكن للمستخدمين تجاوزها. - مكان للقواعد. حدود لكل مستخدم، فحوصات العمر وسياسة السجل كلها تنتمي إلى هذه الطبقة. يبنى دليل السلامة على هذا المسار نفسه.
الخطوة 1: إعداد المشروع
أنشئ مجلداً، قم بتمهيده كمشروع ES-module وثبت Express. كل شيء آخر مدمج.
mkdir lantern-chat && cd lantern-chat
npm init -y
npm pkg set type=module
npm install express
mkdir publicستنتهي بأمورين: server.js في الجذر ومجلد public يحتوي على الصفحة وسكربتاتها.
الخطوة 2: كتابة وسيط Express
يقوم الخادم بتقديم الملفات الثابتة ويعرض مسار POST واحد. اقرأه في ثلاثة أجزاء. أولاً، يقوم بتنظيف السجل الوارد: تمرير أدوار user و assistant فقط، مع اقتطاع كل رسالة إلى 4,000 حرف، والاحتفاظ بآخر 30 دوراً فقط. ثانياً، يضيف رسالة النظام الخاصة بك في البداية، والتي لا يمكن للمتصفح تغييرها. ثالثاً، يستدعي نقطة النهاية البعيدة مع stream: true ويمرر البايتات مباشرة إلى العميل.
import express from "express";
const app = express();
app.use(express.json({ limit: "256kb" }));
app.use(express.static("public"));
const UPSTREAM = "https://api.uncensoredchatbotapi.com/v1/chat/completions";
const SYSTEM = "You are Wren, a dry-witted night-shift lighthouse keeper. Stay in character.";
app.post("/api/chat", async (req, res) => {
const history = Array.isArray(req.body.messages) ? req.body.messages : [];
// Keep only well-formed turns and cap the history we forward.
const turns = history
.filter((m) => ["user", "assistant"].includes(m.role) && typeof m.content === "string")
.slice(-30)
.map((m) => ({ role: m.role, content: m.content.slice(0, 4000) }));
if (turns.length === 0) return res.status(400).json({ error: "no messages" });
const upstream = await fetch(UPSTREAM, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "uncensored",
messages: [{ role: "system", content: SYSTEM }, ...turns],
stream: true,
max_tokens: 500,
temperature: 0.8,
}),
});
if (!upstream.ok) {
const info = await upstream.json().catch(() => ({}));
return res.status(upstream.status).json(info);
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
for await (const chunk of upstream.body) res.write(chunk);
res.end();
});
app.listen(3000, () => console.log("http://localhost:3000"));خياران يستحقان الشرح. تمرير البايتات الخام يعني أنك لا تحتاج إلى فهم تنسيق الأحداث على الخادم على الإطلاق. وإرجاع حالة الخادم البعيد للأخطاء يسمح للصفحة بالتفاعل بشكل معقول؛ على سبيل المثال، يعني 402 أن رصيدك فارغ ويعني 429 أن شخصاً ما يذهب بسرعة كبيرة.
الخطوة 3: الصفحة وقارئ البث
الآن الواجهة الأمامية. احفظ هذا باسم public/index.html؛ إنه عاري عمداً حتى تتمكن من إعادة تصميمه لاحقاً.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Lantern Chat</title>
<style>
body { font: 16px system-ui; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
#log { min-height: 320px; border: 1px solid #ccc; padding: 1rem; white-space: pre-wrap; }
.me { color: #234; font-weight: 600; }
form { display: flex; gap: .5rem; margin-top: 1rem; }
input { flex: 1; padding: .6rem; }
</style>
</head>
<body>
<h1>Lantern Chat</h1>
<div id="log"></div>
<form id="form">
<input id="text" autocomplete="off" placeholder="Say something...">
<button>Send</button>
</form>
<script src="chat.js"></script>
</body>
</html>بعد ذلك، public/chat.js. الجزء المثير للاهتمام هو حلقة القراءة. لا تحترم كتل الشبكة حدود الأسطر، لذا نحتفظ بـ buffer، ونقسم على الأسطر الجديدة، ونحتفظ بآخر سطر غير مكتمل حتى وصول المزيد من البيانات. كل سطر كامل يبدأ بـ data: هو JSON، باستثناء علامة [DONE] النهائية.
const log = document.getElementById("log");
const form = document.getElementById("form");
const input = document.getElementById("text");
const history = [];
function addLine(cls, text) {
const div = document.createElement("div");
div.className = cls;
div.textContent = text; // textContent, never innerHTML, for untrusted text
log.appendChild(div);
return div;
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
input.value = "";
history.push({ role: "user", content: text });
addLine("me", "You: " + text);
const bubble = addLine("bot", "");
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history }),
});
if (!res.ok) {
bubble.textContent = "(the chat is unavailable right now)";
history.pop();
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", reply = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // keep a partial line for the next read
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
const json = JSON.parse(data);
const piece = json.choices?.[0]?.delta?.content;
if (piece) {
reply += piece;
bubble.textContent = reply;
}
}
}
history.push({ role: "assistant", content: reply });
});لاحظ مصفوفة history. لا تحتفظ واجهة API بأي ذاكرة بين الاستدعاءات، لذا يعيد الصفحة إرسال كامل المحادثة في كل مرة، ويقوم الخادم باقتطاعها. شغّل التطبيق وافتحه في المتصفح:
export API_KEY="paste-your-key-here"
node server.js
ملاحظة حول عرض Markdown
نماذج الدردشة تحب النجوم والقوائم وأحياناً سياقات الكود. يعرض عرضنا التوضيحي نصاً خاماً، وهو آمن. عندما تريد مخرجاً جميلاً، اعرض النص المنسق باستخدام مكتبة، ولكن اتبع قاعدتين. شغل HTML عبر منقي قبل إدراجه باستخدام innerHTML، لأن النموذج، أو المستخدم الذي يخدعه، يمكنه إصدار وسوم وسمات لم تقصدها. و اعرض تدريجياً بعناية: إعادة تحليل الرد الكامل في كل جزء أمر جيد للرسائل القصيرة، ولكن يمكن أن يومض النص المنسق غير المكتمل، لذا يظهر بعض المصممين نصاً خاماً أثناء البث ويتحولون إلى مخرج منسق عند انتهاء البث.
تنسيق لعب الأدوار هو غرابة منفصلة. تغلف العديد من الشخصيات الإجراءات بالنجوم، مثل *يعدل المصباح*. قرر ما إذا كان تطبيقك ينسق هذه كخط مائل، وأخبر الشخصية في الموجّه النظامي بالاعتماد على الاتفاقية التي تتبعها. يُظهر دليل تصميم الشخصيات صياغة الموجّه لذلك.
الخطوة 4: اختبار مسار التدخين
قبل إلقاء اللوم على المتصفح، اختبر الوكيل مباشرة. إذا كان الطرفية يعمل، فالخادم بخير وأي خطأ متبقي موجود في الصفحة.
curl -N http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Wren, is the fog coming in?"}]}'يُعطّل العلم -N التخزين المؤقت الخاص بـ curl، لذا يجب أن ترى أسطر data: تظهر تدريجياً، وتنتهي بـ data: [DONE]. إذا حصلت على خطأ JSON بدلاً من ذلك، اقرأ حالته: يعني 401 أن المفتاح في بيئتك خاطئ، ويعني 402 أن الرصيد فارغ، ويعني 404 أن عنوان URL البعيد يحتوي على خطأ مطبعي.
| العرض | السبب المحتمل | الحل |
|---|---|---|
| الصفحة تظهر رسالة غير متاحة | أعاد الخادم حالة غير 200 | قم بتشغيل اختبار curl واقرأ الحالة |
| يظهر النص فقط في النهاية | طبقة وسيطة تخزن الاستجابة مؤقتاً | تعطيل التخزين المؤقت للمسار |
| أحرف مشوهة | مُحلل الترميز المستخدم بدون stream: true | احتفظ بالخيار مضبوطاً على TextDecoder.decode |
| الرد ينقطع في منتصف الجملة | max_tokens تم بلوغه | رفع الحد الأقصى على الخادم |
بهذه الإصلاحات الأربعة يمكنك تشخيص تقريباً كل مشكلة تشغيل أولي في أقل من دقيقة. احتفظ بأمر curl في ملاحظاتك؛ إنه أيضاً فحص صحة مفيد بعد تغيير الوسيط لاحقاً.
لمسات صغيرة تجعله يبدو مكتملاً
دردشة تتدفق هي بالفعل ممتعة، لكن بعض التفاصيل تفصل بين عرض توضيحي ومنتج يعود إليه الناس.
- عطّل زر الإرسال أثناء وصول الرد. الإرسال المتكرر يخلق سجلات متداخلة تربك المستخدم والنموذج على حد سواء.
- أضف زر إيقاف. أنشئ
AbortController، ومرر إشارة الـfetch، واستدعيabort()عند النقر. احفظ أي نص وصل حتى الآن كدور المساعد. - حافظ على السجل. احتفظ بالسجل في
sessionStorageحتى لا يؤدي التحديث إلى مسح المحادثة، وقدم زر مسح الدردشة الذي يفرغه. - تمرير تلقائي بعقلانية. اتبع الأسفل فقط عندما يكون المستخدم قريباً منه بالفعل؛ وإلا اسمح له بقراءة الأسطر الأقدم بسلام.
- إظهار فشل لطيف. استبدل الفقاعة الفارغة برابط إعادة المحاولة الذي يعيد إرسال رسالة المستخدم الأخيرة.
كل هذه عبارة عن بضعة أسطر من JavaScript العادي، لذا قاوم الوصول إلى إطار عمل حتى تحتاج واجهتك حقاً إلى مكونات أو توجيه أو حالة مشتركة.
التقوية والخطوات التالية
لديك الآن دردشة عاملة. قبل وصول المستخدمين الحقيقيين، أضف بعض إجراءات الحماية في server.js. حدّ عدد الطلبات لكل عنوان IP أو جلسة، لأن المفتاح الواحد مسموح له بـ 300 طلب في الدقيقة إجمالاً، وقد يستهلك مستخدم واحد كل الرصيد. تعامل مع أخطاء الطرف البعيد صراحةً: خطأ 503 مع upstream_busy يستحق زر إعادة محاولة ودود، وخطأ 403 مع content_blocked يستحق رسالة واضحة بدلاً من فقاعة فارغة. اجعل max_tokens متواضعاً؛ 500 كافية للدردشة، بينما الحد الأقصى المسموح به هو 32,000 لكل طلب.
ثم فكر في التكلفة. كمثال، افترض أن كل دور يرسل 1,200 رمز موجّه ويستقبل 250 رمز إكمال. هذا حوالي $0.0003 للإدخال و $0.00025 للإخراج، أي حوالي $0.00055 لكل دور. هذه أعداد الرموز هي افتراضات، لذا قس الخاصة بك باستخدام قطعة usage. يغطي الدليل هذا الحقل، ويشرح دليل نماذج LLM المستضافة بدون رقابة ما يمكن توقعه من هذا النوع من الخدمات.
أسئلة وأجوبة
هل يمكنني استدعاء API مباشرة من المتصفح؟
نعم تقنيًا، لكنك ستكشف مفتاحك لكل زائر. استخدم مسار خادم مثل الموجود في هذا البرنامج التعليمي بحيث يبقى المفتاح في متغير بيئة.
هل يحتاج الخادم إلى تحليل البث؟
لا. يمكنه توجيه البايتات كما هي، وتقرأ الصفحة أسطر البيانات. حلل على الخادم فقط إذا كنت تريد تسجيل النص أو تصفية المخرجات.
لماذا يصل ردي دفعة واحدة؟
شيء ما في المنتصف يقوم بالتخزين المؤقت. تحقق من أن الطلب يحدد stream إلى true وأن أي وكيل عكسي أو طبقة ضغط لا يحتفظ بالاستجابة.
كيف أعطي البوت ذاكرة؟
أعد إرسال المحادثة في الرسائل في كل استدعاء، مع تقصير الدور الأقدم عندما يزداد حجمها. نافذة السياق المكونة من 100,000 رمز تُشارك مع الرد.
مفتاحك على بُعد نموذج واحد
أنشئ حسابًا، انسخ المفتاح، غيّر عنوان URL الأساسي. هذا هو الإعداد الكامل.