讓我們建立串流網頁聊天(Express 代理 + 純 JavaScript)
在本教學中,你將建立一個約 100 行的簡易聊天頁面。一個輕量的 Express 伺服器持有你的金鑰並將請求轉送至 Uncensored Chatbot API,而純 JavaScript 前端則逐字讀取串流輸出的回覆。無需框架、無需建置步驟,且你的金鑰不會傳到瀏覽器。
更新於
重點
- 永遠不要從瀏覽器程式碼呼叫 API;伺服器路由可讓金鑰保持私密並允許你驗證輸入。
- 代理只需透過伺服器發送的事件;頁面解析資料行直到 [DONE]。
- 串流期間使用 textContent,並在渲染 Markdown 前進行消毒。
- 在伺服器端限制歷史長度與 max_tokens,以免單一使用者耗盡你的餘額。
我們要建立什麼,以及為何需要代理
想像一個包含文字輸入框和滾動日誌的單一頁面。你輸入一行文字,頁面將對話發送到你自己伺服器上的 /api/chat,你的伺服器會將請求轉送至上游並附上真實金鑰。回覆會串流回來,每個片段都會在到達時立即顯示。我們將賦予機器人一點個性,一位名叫 Wren 的燈塔看守員,讓演示更具生動感。
你需要 Node 18 或更新版本(用於內建 fetch)以及 API 金鑰。如果你還沒有金鑰,請從 金鑰頁面取得免費試用額度:新帳號可獲得 $0.50 的額度,有效期 7 天,無需提供付款資訊。Base URL 為 https://api.uncensoredchatbotapi.com/v1,模型 ID 為 uncensored。
為何你需要代理
將金鑰貼入前端程式碼並直接呼叫 API 很有誘惑力。請不要這樣做。任何傳送至瀏覽器的程式碼都可以被任何開啟開發者工具的人讀取,而金鑰洩漏意味著餘額被耗盡。代理可以解決這個問題,並提供三個額外的好處。
- 機密性。 金鑰位於伺服器的環境變數中。
- 控制權。 你可以決定系統提示詞、歷史長度與
max_tokens,讓使用者無法覆蓋它們。 - 規則存放之處。 每個使用者的限制、年齡門檻與記錄政策都應歸屬於此層級。安全指南 建立在這個路由之上。
步驟 1:設定專案
建立一個資料夾,將其初始化為 ES 模組專案並安裝 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 渲染的注意事項
聊天模型喜歡星號、列表和偶爾出現的程式碼區塊。我們的示範顯示純文字,這很安全。當你想要漂亮的輸出時,請使用函式庫渲染 Markdown,但請遵循兩項規則。在透過 innerHTML 插入之前,先將 HTML 經過消毒器處理,因為模型或誘騙它的用戶可能會產生你未預期的標籤和屬性。並且謹慎地增量渲染:對每個片段重新解析整個回覆對於短訊息來說沒問題,但未完成的 Markdown 可能會閃爍,因此某些開發者在串流輸出期間顯示純文字,並在串流結束時切換為格式化輸出。
角色扮演格式是一個獨立的怪癖。許多角色會用星號將動作包起來,例如 *調整燈具*。決定你的應用程式是否將這些樣式化為斜體,並在系統提示詞中告訴角色遵循哪種慣例。我們的角色設計指南 展示了相關的提示詞寫法。
步驟 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 次請求,而一個急切的使用者可能會耗盡所有額度。明確處理上游錯誤:帶有 upstream_busy 的 503 錯誤值得一個友善的重試按鈕,而 403 content_blocked 則應顯示清晰的訊息而非空白對話框。將 max_tokens 保持適中;聊天用途 500 已足夠,而每個請求允許的最大值是 32,000。
接著考慮成本。作為範例,假設每個回合發送 1,200 個提示詞 token 並接收 250 個完成 token。這大約是輸入 $0.0003 和輸出 $0.00025,每個回合約 $0.00055。這些 token 計數是假設值,因此請使用 usage 區塊測量你自己的數據。文件涵蓋該欄位,託管無審查 LLM 指南 則說明此類服務的預期情況。
問答
我可以從瀏覽器直接呼叫 API 嗎?
技術上可以,但這會將你的金鑰暴露給所有訪客。使用本教學中的伺服器路由,讓金鑰保留在環境變數中。
伺服器需要解析串流嗎?
不需要。它可以原封不動地轉送位元組,頁面則讀取資料行。如果你想要記錄文字或過濾輸出,才在伺服器端進行解析。
為什麼我的回覆會一次全部到達?
中間的某個環節正在緩衝。檢查請求是否設定了 stream 為 true,以及任何反向代理或壓縮層是否正在保留回應。
我該如何讓機器人擁有記憶?
在每次呼叫時以訊息形式重新傳送對話,並在對話變長時移除最舊的回合。100,000 token 的上下文視窗與回覆共用。