มาสร้างเว็บแชทแบบสตรีม (Express Proxy + JS ธรรมดา)
ในบทช่วยสอนนี้ คุณจะได้สร้างหน้าแชทขนาดเล็กด้วยโค้ดประมาณ 100 บรรทัด เซิร์ฟเวอร์ Express ขนาดเล็กจะเก็บคีย์ลับของคุณและส่งต่อคำขอไปยัง Uncensored Chatbot API ในขณะที่ส่วนหน้าแบบ JavaScript ธรรมดาจะอ่านการตอบกลับแบบสตรีมมิงทีละคำ ไม่มีเฟรมเวิร์ก ไม่มีขั้นตอนการ build และคีย์ของคุณจะไม่ถูกส่งไปยังเบราว์เซอร์
อัปเดตเมื่อ
จุดสำคัญ
- อย่าเรียก API จากโค้ดเบราว์เซอร์เลย เส้นทางเซิร์ฟเวอร์จะเก็บคีย์ให้เป็นส่วนตัวและอนุญาตให้คุณตรวจสอบอินพุต
- โปรxies ต้องการเพียงส่งเหตุการณ์ที่ส่งโดยเซิร์ฟเวอร์ผ่าน; หน้าเว็บจะแยกวิเคราะห์บรรทัดข้อมูลจนกว่าจะถึง [DONE]
- ใช้ textContent ขณะสตรีมและทำเครื่องหมายให้ปลอดภัยก่อนเรนเดอร์มาร์กดาวน์
- จำกัดความยาวประวัติและ max_tokens บนเซิร์ฟเวอร์เพื่อให้ผู้ใช้หนึ่งรายไม่สามารถใช้เงินคงเหลือทั้งหมดของคุณได้
สิ่งที่เรากำลังสร้าง และเหตุใดจึงต้องการโปรxies
จินตนาการถึงหน้าเว็บที่มีช่องข้อความและบันทึกการสนทนาแบบเลื่อนดูได้ คุณพิมพ์ข้อความ หน้าเว็บจะส่งการสนทนาไปยัง /api/chat บนเซิร์ฟเวอร์ของคุณ และเซิร์ฟเวอร์ของคุณจะส่งต่อไปยัง upstream โดยแนบคีย์จริงไปด้วย การตอบกลับจะถูกสตรีมกลับ และแต่ละส่วนจะปรากฏทันทีที่ได้รับ เราจะกำหนดบุคลิกเล็กน้อยให้กับบอท โดยเป็นยามประภาสชื่อ Wren เพื่อให้การสาธิตดูมีชีวิตชีวา
คุณต้องมี Node 18 ขึ้นไป (เพื่อใช้ fetch ในตัว) และคีย์ API หากคุณยังไม่มี ให้รับเครดิตทดลองใช้ฟรีจากหน้า key: บัญชีใหม่จะได้รับเครดิต $0.50 เป็นเวลา 7 วัน โดยไม่ต้องใส่รายละเอียดการชำระเงิน Base URL คือ https://api.uncensoredchatbotapi.com/v1 และ id ของโมเดลคือ uncensored
เหตุใดคุณจึงต้องการโปรxies
tempting ที่จะวางคีย์ลงในโค้ด front-end และเรียก API โดยตรง กรุณาอย่าทำ สิ่งใดๆ ที่ส่งไปยังเบราว์เซอร์สามารถอ่านได้โดยทุกคนที่เปิดเครื่องมือพัฒนา และคีย์ที่รั่วไหลหมายถึงยอดเงินหมด โปรxies แก้ไขปัญหานี้และให้ประโยชน์เพิ่มเติมสามประการ
- ความลับ คีย์จะอยู่ในตัวแปรสภาพแวดล้อมบนเซิร์ฟเวอร์
- การควบคุม คุณตัดสินใจพรอมต์ระบบ ความยาวประวัติและ
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: เขียน proxy Express
เซิร์ฟเวอร์ให้บริการไฟล์แบบสแตติกและเปิดใช้งานเส้นทาง POST หนึ่งเส้นทาง อ่านโค้ดนี้สามส่วน ส่วนแรกจะทำความสะอาดประวัติที่ได้รับ: เฉพาะบทบาท user และ assistant เท่านั้นที่ผ่านได้ แต่ละข้อความจะถูกตัดให้เหลือ 4,000 ตัวอักษร และเก็บเฉพาะการสนทนา 30 ครั้งล่าสุด ส่วนที่สอง จะเพิ่มข้อความระบบของคุณซึ่งเบราว์เซอร์ไม่สามารถเปลี่ยนแปลงได้ ส่วนที่สาม จะเรียกใช้เอนด์พอยต์ upstream ด้วย 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"));มีสองทางเลือกที่ควรอธิบาย การส่งข้อมูลดิบผ่านหมายความว่าคุณไม่จำเป็นต้องเข้าใจรูปแบบเหตุการณ์บนเซิร์ฟเวอร์เลย และการส่งสถานะ upstream กลับเมื่อเกิดข้อผิดพลาดทำให้หน้าเว็บสามารถตอบสนองได้อย่างเหมาะสม เช่น รหัส 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 เนื่องจากโมเดลหรือผู้ใช้ที่หลอกมันสามารถปล่อยแท็กและแอตทริบิวต์ที่คุณไม่ได้ตั้งใจ และ เรนเดอร์แบบเพิ่มทีละส่วนอย่างระมัดระวัง: การวิเคราะห์ข้อความตอบกลับทั้งหมดใหม่ในทุกส่วนนั้นใช้ได้สำหรับข้อความสั้น ๆ แต่ markdown ที่ยังไม่เสร็จอาจกระพริบ ดังนั้นผู้สร้างบางรายจึงแสดงข้อความธรรมดาขณะสตรีม และสลับไปแสดงผลแบบจัดรูปแบบเมื่อสตรีมสิ้นสุด
การจัดรูปแบบ Roleplay เป็นข้อผิดพลาดเฉพาะตัว ตัวละครจำนวนมากห่อหุ้มการกระทำด้วยเครื่องหมายดอกจัน เช่น *ปรับโคมไฟ* ตัดสินใจว่าแอปของคุณจะจัดรูปแบบตัวเอียงเหล่านั้นหรือไม่ และบอกตัวละครในพรอมต์ระบบให้ปฏิบัติตามข้อตกลงใดข้อตกลงหนึ่ง คู่มือ การออกแบบบุคลิก แสดงคำพูดพรอมต์สำหรับสิ่งนั้น
ขั้นตอนที่ 4: smoke-test เส้นทาง
ก่อนโทษเบราว์เซอร์ ให้ทดสอบโปรxies โดยตรง หากเทอร์มินัลทำงาน เซิร์ฟเวอร์ก็โอเค และข้อบกพร่องที่เหลืออยู่ในหน้าเว็บ
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 upstream มีตัวพิมพ์ผิด
| อาการ | สาเหตุที่เป็นไปได้ | การแก้ไข |
|---|---|---|
| หน้าเว็บแสดงข้อความว่าไม่สามารถใช้งานได้ | เซิร์ฟเวอร์ส่งสถานะที่ไม่ใช่ 200 | รันการทดสอบ curl และอ่านสถานะ |
| ข้อความปรากฏเฉพาะในตอนท้าย | ชั้นพร็อกซีทำการบัฟเฟอร์การตอบกลับ | ปิดการบัฟเฟอร์สำหรับเส้นทาง |
| อักขระที่ผิดรูปแบบ | ตัวถอดรหัสที่ใช้โดยไม่มี stream: true | ตั้งค่าตัวเลือกบน TextDecoder.decode |
| การตอบตัดกลางประโยค | max_tokens ถึงขีดจำกัด | เพิ่มขีดจำกัดบนเซิร์ฟเวอร์ |
ด้วยการแก้ไขสี่จุดนี้ คุณสามารถวินิจฉัยปัญหาส่วนใหญ่ในการรันครั้งแรกได้ภายในหนึ่งนาที เก็บคำสั่ง curl ไว้ในบันทึกของคุณ เพราะมันยังเป็นเครื่องมือตรวจสอบสุขภาพที่มีประโยชน์หลังจากที่คุณเปลี่ยน proxy ในภายหลัง
รายละเอียดเล็กๆ น้อยๆ ที่ทำให้ดูสมบูรณ์
แชทที่สตรีมมิงอยู่แล้วนั้นให้ความรู้สึกดี แต่รายละเอียดไม่กี่อย่างคือสิ่งที่แยกเดโมออกจากผลิตภัณฑ์ที่ผู้คนกลับมาใช้งานซ้ำ
- ปิดปุ่มส่งขณะรอรับข้อความตอบกลับ การส่งซ้ำสองครั้งสร้างประวัติที่สลับซับซ้อนซึ่งทำให้ทั้งผู้ใช้และโมเดลสับสน
- เพิ่มปุ่มหยุด สร้าง
AbortControllerส่งสัญญาณไปยังfetchและเรียกabort()เมื่อคลิก บันทึกข้อความที่ได้รับจนถึงปัจจุบันเป็นการตอบกลับของผู้ช่วย - บันทึกบันทึกการสนทนา เก็บประวัติไว้ใน
sessionStorageเพื่อให้การรีเฟรชไม่ลบการสนทนา และเสนอปุ่มล้างแชทที่ล้างข้อมูลนั้น - เลื่อนอัตโนมัติอย่างเหมาะสม ติดตามด้านล่างเฉพาะเมื่อผู้ใช้อยู่ใกล้แล้วเท่านั้น; มิฉะนั้นให้พวกเขาอ่านบรรทัดเก่าได้อย่างสงบสุข
- แสดงข้อผิดพลาดอย่างสุภาพ แทนที่ฟองว่างด้วยลิงก์ลองส่งใหม่ซึ่งส่งข้อความผู้ใช้ล่าสุดอีกครั้ง
แต่ละรายการนี้ใช้โค้ด JavaScript ธรรมดาเพียงไม่กี่บรรทัด ดังนั้นจงต้านทานแรงที่จะใช้เฟรมเวิร์กจนกว่าอินเทอร์เฟซของคุณจะต้องการคอมโพเนนต์ การกำหนดเส้นทาง หรือสถานะที่แชร์จริงๆ
การทำให้แข็งแกร่งและขั้นตอนถัดไป
ตอนนี้คุณมีแชทที่ใช้งานได้ ก่อนที่ผู้ใช้จริงจะเข้ามา ให้เพิ่มมาตรการป้องกันใน server.js จำกัดจำนวนคำขอต่อ IP หรือเซสชัน เพราะคีย์หนึ่งๆ อนุญาตให้ส่งคำขอได้ 300 คำขอต่อนาที และผู้ใช้ที่กระตือรือรือคนหนึ่งอาจใช้ยอดทั้งหมดได้ จัดการข้อผิดพลาดจาก upstream อย่างชัดเจน: ข้อผิดพลาด 503 พร้อม upstream_busy ควรได้รับปุ่มลองใหม่ที่เป็นมิตร และข้อผิดพลาด 403 content_blocked ควรได้รับข้อความที่ชัดเจนแทนที่จะเป็นฟองข้อความว่างเปล่า เก็บ max_tokens ให้เหมาะสม; 500 เพียงพอสำหรับการแชท ในขณะที่ค่าสูงสุดที่อนุญาตคือ 32,000 ต่อคำขอ
จากนั้นให้พิจารณาเรื่องค่าใช้จ่าย ตัวอย่างเช่น สมมติว่าการสนทนาแต่ละรอบส่ง 1,200 โทเคนพรอมต์และรับ 250 โทเคนการเติมเต็ม นั่นคือประมาณ $0.0003 สำหรับข้อมูลนำเข้า และ $0.00025 สำหรับข้อมูลส่งออก รวมประมาณ $0.00055 ต่อการสนทนา จำนวนโทเคนเหล่านี้เป็นเพียงการสมมติ ดังนั้นควรวัดค่าจริงของคุณด้วยข้อมูล usage docs อธิบายฟิลด์นี้ และ guide to hosted uncensored LLMs อธิบายสิ่งที่ควรคาดหวังจากบริการประเภทนี้
ถาม-ตอบ
ฉันสามารถเรียก API โดยตรงจากเบราว์เซอร์ได้หรือไม่?
ในทางเทคนิคใช่ แต่คุณจะทำให้คีย์ของคุณเปิดเผยต่อผู้เยี่ยมชมทุกคน ใช้เส้นทางเซิร์ฟเวอร์เช่นในบทช่วยสอนนี้เพื่อให้คีย์อยู่ในตัวแปรสภาพแวดล้อม
เซิร์ฟเวอร์จำเป็นต้องแยกวิเคราะห์สตรีมหรือไม่?
ไม่ มันสามารถส่งต่อไบต์โดยไม่เปลี่ยนแปลง และหน้าเว็บจะอ่านบรรทัดข้อมูล แยกวิเคราะห์บนเซิร์ฟเวอร์ก็ต่อเมื่อคุณต้องการบันทึกข้อความหรือกรองเอาต์พุต
ทำไมคำตอบของฉันจึงมาถึงพร้อมกันทั้งหมด?
บางอย่างระหว่างนั้นคือการบัฟเฟอร์ ตรวจสอบว่าคำขอตั้งค่า stream เป็น true และว่า proxy ย้อนกลับหรือชั้นการบีบอัดไม่ได้กักเก็บการตอบกลับ
ฉันจะให้หน่วยความจำกับบอทได้อย่างไร?
ส่งการสนทนาในรูปแบบข้อความในทุกคำขอ โดยตัดการสนทนาที่เก่าที่สุดออกเมื่อข้อความมีจำนวนมาก หน้าต่างบริบทขนาด 100,000 โทเคนจะถูกแชร์ร่วมกับข้อความตอบกลับ
คีย์ของคุณอยู่ห่างแค่แบบฟอร์มเดียว
สร้างบัญชี คัดลอกคีย์ เปลี่ยน base URL นั่นคือการตั้งค่าทั้งหมด