Hãy cùng xây dựng ứng dụng web chat truyền phát (Proxy Express + JS thuần)
Trong hướng dẫn này, bạn sẽ xây dựng một trang chat nhỏ gồm khoảng 100 dòng. Máy chủ Express nhỏ sẽ giữ khóa bí mật của bạn và chuyển tiếp các yêu cầu đến Uncensored Chatbot API, trong khi giao diện JavaScript thuần sẽ đọc phản hồi được truyền phát từng từ một. Không có framework, không có bước xây dựng và khóa của bạn sẽ không bao giờ chạm vào trình duyệt.
Đã cập nhật
Các điểm chính
- Không bao giờ gọi API từ mã trình duyệt; một tuyến máy chủ sẽ giữ khóa riêng tư và cho phép bạn xác thực đầu vào.
- Proxy chỉ cần chuyển các sự kiện do máy chủ gửi đi; trang web sẽ phân tích cú pháp các dòng dữ liệu cho đến khi nhận được [DONE].
- Sử dụng textContent trong khi truyền phát và làm sạch dữ liệu trước khi hiển thị markdown.
- Giới hạn độ dài lịch sử và max_tokens trên máy chủ để một người dùng không thể tiêu tốn toàn bộ số dư của bạn.
Chúng ta đang xây dựng gì và tại sao cần proxy
Hãy tưởng tượng một trang đơn có một hộp văn bản và nhật ký cuộn. Bạn nhập một dòng, trang web gửi cuộc trò chuyện đến /api/chat trên máy chủ của riêng bạn, và máy chủ của bạn chuyển tiếp nó lên trên với khóa thật được đính kèm. Phản hồi được truyền phát lại và mỗi phần tử xuất hiện ngay khi nó đến. Chúng ta sẽ cấp cho bot một chút tính cách, một người giữ hải đăng tên là Wren, để bản demo có vẻ sống động.
Bạn cần Node 18 trở lên (để dùng fetch tích hợp sẵn) và một khóa API. Nếu bạn chưa có, hãy nhận tín dụng dùng thử miễn phí từ trang khóa API: tài khoản mới được nhận $0,50 tín dụng trong 7 ngày, không cần cung cấp thông tin thanh toán. Base URL là https://api.uncensoredchatbotapi.com/v1 và model id là uncensored.
Tại sao bạn cần proxy
Rất dễ dàng dán khóa vào mã giao diện người dùng và gọi API trực tiếp. Xin đừng làm vậy. Mọi thứ được gửi đến trình duyệt đều có thể bị đọc bởi bất kỳ ai mở công cụ dành cho nhà phát triển và khóa bị lộ có nghĩa là số dư bị cạn kiệt. Proxy khắc phục điều này và mang lại cho bạn ba lợi ích bổ sung.
- Tính bảo mật. Khóa được lưu trong biến môi trường trên máy chủ.
- Kiểm soát. Bạn quyết định system prompt, độ dài lịch sử và
max_tokens, vì vậy người dùng không thể ghi đè chúng. - Một nơi cho các quy tắc. Giới hạn theo người dùng, kiểm soát độ tuổi và chính sách ghi log đều thuộc về lớp này. hướng dẫn an toàn được xây dựng dựa trên đường dẫn này.
Bước 1: thiết lập dự án
Tạo một thư mục, khởi tạo nó dưới dạng dự án mô-đun ES và cài đặt Express. Mọi thứ khác đều được xây dựng sẵn.
mkdir lantern-chat && cd lantern-chat
npm init -y
npm pkg set type=module
npm install express
mkdir publicBạn sẽ có hai thứ: server.js ở thư mục gốc và một thư mục public chứa trang và script của nó.
Bước 2: viết proxy Express
Máy chủ phục vụ các tệp tĩnh và công khai một endpoint POST. Hãy đọc nó theo ba phần. Đầu tiên, nó làm sạch lịch sử đầu vào: chỉ các vai trò user và assistant được chấp nhận, mỗi tin nhắn được cắt ngắn còn 4.000 ký tự, và chỉ giữ lại 30 lượt tin cuối cùng. Thứ hai, nó thêm vào đầu tin nhắn hệ thống của bạn, mà trình duyệt không bao giờ có thể thay đổi. Thứ ba, nó gọi endpoint phía máy chủ khác với stream: true và chuyển tiếp các byte trực tiếp trở lại.
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"));Hai lựa chọn đáng được giải thích. Chuyển tiếp các byte thô có nghĩa là bạn không cần phải hiểu định dạng sự kiện trên máy chủ. Và trả lại trạng thái upstream cho các lỗi cho phép trang web phản ứng hợp lý; ví dụ: 402 có nghĩa là số dư của bạn đã trống và 429 có nghĩa là ai đó đang gửi quá nhanh.
Bước 3: trang web và trình đọc luồng dữ liệu
Bây giờ là phía máy khách. Hãy lưu nội dung này vào public/index.html; nó được thiết kế đơn giản để bạn có thể tùy chỉnh giao diện sau này.
<!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>Tiếp theo, public/chat.js. Phần thú vị là vòng lặp đọc. Các chunk mạng không tuân theo ranh giới dòng, vì vậy chúng ta giữ một buffer, tách theo dấu xuống dòng, và giữ lại dòng cuối cùng chưa hoàn chỉnh cho đến khi có thêm dữ liệu. Mỗi dòng đầy đủ bắt đầu bằng data: là JSON, ngoại trừ dấu hiệu [DONE] cuối cùng.
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 });
});Lưu ý mảng history. API không giữ bộ nhớ giữa các lần gọi, vì vậy trang web gửi lại toàn bộ cuộc trò chuyện mỗi lần, và máy chủ sẽ cắt ngắn nó. Khởi chạy ứng dụng và mở nó trong trình duyệt:
export API_KEY="paste-your-key-here"
node server.js
Lưu ý về việc hiển thị markdown
Các mô hình trò chuyện rất thích dấu hoa thị, danh sách và các khối mã code đôi khi xuất hiện. Bản demo của chúng tôi hiển thị văn bản thô, điều này an toàn. Khi bạn muốn hiển thị văn bản đẹp, hãy render markdown bằng một thư viện, nhưng hãy tuân thủ hai quy tắc. Chạy HTML qua một bộ làm sạch trước khi chèn nó bằng innerHTML, vì một mô hình, hoặc một người dùng lừa nó, có thể phát ra các thẻ và thuộc tính mà bạn không mong muốn. Và render tăng dần một cách cẩn thận: việc phân tích lại toàn bộ phản hồi trên mỗi phần dữ liệu là ổn đối với các tin nhắn ngắn, nhưng markdown chưa hoàn thành có thể gây nhấp nháy, vì vậy một số nhà phát triển hiển thị văn bản thuần túy trong khi truyền phát và chuyển sang văn bản đã định dạng khi luồng truyền phát kết thúc.
Định dạng nhập vai là một điểm đặc biệt riêng. Nhiều nhân vật bao quanh các hành động bằng dấu hoa thị, chẳng hạn như *điều chỉnh đèn*. Hãy quyết định xem ứng dụng của bạn có định dạng các hành động đó thành in nghiêng hay không, và yêu cầu nhân vật trong prompt hệ thống tuân theo quy ước nào. hướng dẫn thiết kế nhân vật của chúng tôi hiển thị cách đặt câu lệnh prompt cho việc đó.
Bước 4: kiểm tra nhanh tuyến
Trước khi đổ lỗi cho trình duyệt, hãy kiểm tra proxy trực tiếp. Nếu terminal hoạt động, máy chủ ổn và mọi lỗi còn lại nằm ở trang web.
curl -N http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Wren, is the fog coming in?"}]}'Cờ -N vô hiệu hóa bộ đệm của curl, vì vậy bạn sẽ thấy các dòng data: xuất hiện dần dần, kết thúc bằng data: [DONE]. Nếu bạn nhận được lỗi JSON thay vào đó, hãy đọc trạng thái của nó: 401 có nghĩa là khóa trong môi trường của bạn không đúng, 402 có nghĩa là số dư trống, và 404 có nghĩa là URL phía máy chủ khác bị sai chính tả.
| Triệu chứng | Nguyên nhân có thể | Cách khắc phục |
|---|---|---|
| Trang hiển thị thông báo không khả dụng | Máy chủ trả về trạng thái không phải 200 | Chạy bài kiểm tra curl và đọc trạng thái |
| Văn bản chỉ xuất hiện ở cuối | Một lớp proxy đệm phản hồi | Vô hiệu hóa đệm cho tuyến |
| Ký tự bị lỗi | Decoder được sử dụng mà không có stream: true | Giữ tùy chọn được đặt trên TextDecoder.decode |
| Phản hồi bị ngắt quãng giữa câu | max_tokens đã đạt đến giới hạn | Tăng giới hạn trên của máy chủ |
Với bốn bản sửa lỗi đó, bạn có thể chẩn đoán gần như mọi vấn đề khi chạy lần đầu trong chưa đầy một phút. Hãy giữ lệnh curl trong ghi chú của bạn; nó cũng là một công cụ kiểm tra sức khỏe hữu ích sau khi bạn thay đổi proxy sau này.
Những chi tiết nhỏ giúp giao diện hoàn thiện hơn
Một cuộc trò chuyện có truyền phát (streaming) đã khá dễ chịu, nhưng một vài chi tiết phân biệt bản demo với một sản phẩm mà người dùng muốn quay lại.
- Tắt nút gửi trong khi phản hồi đang đến. Việc gửi trùng lặp tạo ra lịch sử xen kẽ gây nhầm lẫn cho cả người dùng và mô hình.
- Thêm nút dừng. Tạo một
AbortController, truyền signal của nó vàofetchvà gọiabort()khi nhấp chuột. Lưu lại bất kỳ văn bản nào đã nhận được cho đến thời điểm đó làm lượt của trợ lý. - Lưu trữ log. Giữ lịch sử trong
sessionStorageđể việc làm mới không xóa hội thoại, và cung cấp một nút xóa lịch sử để làm trống nó. - Tự động cuộn hợp lý. Chỉ cuộn xuống dưới cùng khi người dùng đã ở gần đó; nếu không, hãy để họ đọc các dòng cũ một cách yên bình.
- Hiển thị lỗi nhẹ nhàng. Thay thế bong bóng trống bằng một liên kết thử lại để gửi lại tin nhắn cuối cùng của người dùng.
Mỗi tính năng này chỉ tốn vài dòng JavaScript thuần túy, vì vậy hãy kiềm chế việc sử dụng framework cho đến khi giao diện của bạn thực sự cần các thành phần, định tuyến hoặc trạng thái chia sẻ.
Củng cố và các bước tiếp theo
Giờ bạn đã có một ứng dụng trò chuyện hoạt động. Trước khi người dùng thực sự truy cập, hãy thêm một số biện pháp bảo vệ trong server.js. Giới hạn số lượng yêu cầu theo IP hoặc phiên, vì một khóa duy nhất được phép thực hiện 300 yêu cầu mỗi phút tổng cộng, và một người dùng háo hức có thể sử dụng hết số lượng đó. Xử lý các lỗi phía máy chủ khác một cách rõ ràng: lỗi 503 với upstream_busy xứng đáng có một nút thử lại thân thiện, và lỗi 403 content_blocked xứng đáng có một thông báo rõ ràng thay vì một bong bóng trống. Hãy giữ max_tokens ở mức vừa phải; 500 là đủ cho trò chuyện, trong khi giới hạn tối đa cho phép là 32.000 trên mỗi yêu cầu.
Sau đó hãy nghĩ về chi phí. Để minh họa, giả sử mỗi lượt gửi 1.200 token prompt và nhận 250 token hoàn tất. Điều đó tốn khoảng $0,0003 cho đầu vào và $0,00025 cho đầu ra, tổng cộng khoảng $0,00055 cho một lượt. Những số lượng token này là giả định, vì vậy hãy đo lường số lượng token thực tế của bạn với khối usage. tài liệu bao gồm trường đó, và hướng dẫn về LLM được lưu trữ không kiểm duyệt giải thích những gì bạn có thể mong đợi từ loại dịch vụ này.
Hỏi đáp
Tôi có thể gọi API trực tiếp từ trình duyệt không?
Về mặt kỹ thuật thì có, nhưng bạn sẽ làm lộ khóa API của mình cho mọi khách truy cập. Hãy sử dụng một tuyến đường máy chủ như trong hướng dẫn này để khóa API được giữ trong biến môi trường.
Máy chủ có cần phân tích luồng không?
Không. Máy chủ có thể chuyển tiếp các byte không thay đổi và trang web đọc các dòng dữ liệu. Chỉ phân tích trên máy chủ nếu bạn muốn ghi lại văn bản hoặc lọc đầu ra.
Tại sao phản hồi của tôi lại đến cùng một lúc?
Có thứ gì đó ở giữa đang đệm dữ liệu. Hãy kiểm tra xem yêu cầu có đặt stream thành true và liệu bất kỳ proxy ngược hoặc lớp nén nào có đang giữ phản hồi không.
Tôi làm thế nào để cung cấp bộ nhớ cho bot?
Gửi lại toàn bộ cuộc trò chuyện trong messages trên mỗi lần gọi, cắt bỏ các lượt cũ nhất khi nó phát triển. Cửa sổ ngữ cảnh 100.000 token được chia sẻ với phản hồi.
Khóa của bạn chỉ cách một biểu mẫu
Tạo tài khoản, sao chép khóa API, thay đổi base URL. Đó là toàn bộ quá trình thiết lập.